gabriel / muse public
test_stress_merge_regression.py python
1,270 lines 56.9 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 27 days ago
1 """Regression stress tests for the three-way merge engine — all permutations.
2
3 Root cause (fixed in commit 73427a30):
4 CodePlugin.merge_ops silently dropped theirs-only changes when OT symbol
5 commutation masked a file-level conflict. The merged blob was the ours blob
6 verbatim, so the result reported "clean merge" with no-op file content.
7
8 Real-world impact:
9 MuseHub's executor.py ``--pid=private`` fix (removed in fix/pool-pre-ping)
10 was silently discarded when the user merged local/dev (which had a commuting
11 pool_pre_ping change to database.py). Every subsequent CI run failed with
12 "docker: --pid: invalid PID mode" until the regression was manually tracked
13 down through the object store.
14
15 This file tests every permutation of merge topology that could lead to silent
16 data loss — not just the one that burned us.
17
18 Categories
19 ----------
20 A Fast-forward / up-to-date detection (no data-loss risk, but correctness)
21 B Three-way clean merges — no conflicts anywhere
22 C Three-way with conflicts surfaced — the merge MUST stop, not silently pass
23 D The silent-drop regression — commuting OT ops on same file
24 E Theirs-only files MUST survive when there are conflicts elsewhere
25 F Strategy shortcuts (--strategy=ours / --strategy=theirs) correctness
26 G MuseHub regression scenario (pool_pre_ping + executor + AGENTS.md)
27 H Merge-base correctness for complex DAG topologies
28 """
29 from __future__ import annotations
30
31 import datetime
32 import hashlib
33 import json
34 import pathlib
35 import textwrap
36 import uuid
37
38 import pytest
39 from tests.cli_test_helper import CliRunner
40 from muse.core._types import Manifest
41
42 runner = CliRunner()
43 cli = None # CliRunner ignores this positional arg
44
45
46 # ---------------------------------------------------------------------------
47 # Low-level repo helpers
48 # ---------------------------------------------------------------------------
49
50
51 def _h(label: str) -> str:
52 """Stable content hash for a text label."""
53 return hashlib.sha256(label.encode()).hexdigest()
54
55
56 def _env(root: pathlib.Path) -> Manifest:
57 return {"MUSE_REPO_ROOT": str(root)}
58
59
60 def _run(root: pathlib.Path, *args: str) -> tuple[int, str]:
61 """Run a muse command, injecting --force into merge calls.
62
63 Tests use an in-memory manifest-only setup (no files on disk) so the
64 working-tree cleanliness guard would always fire. ``--force`` bypasses
65 that guard without affecting any merge-logic correctness being tested.
66 """
67 final_args = list(args)
68 if final_args and final_args[0] == "merge" and "--force" not in final_args:
69 final_args.insert(1, "--force")
70 result = runner.invoke(cli, final_args, env=_env(root), catch_exceptions=False)
71 return result.exit_code, result.output
72
73
74 def _run_unchecked(root: pathlib.Path, *args: str) -> tuple[int, str]:
75 """Like _run but does not raise on failure."""
76 final_args = list(args)
77 if final_args and final_args[0] == "merge" and "--force" not in final_args:
78 final_args.insert(1, "--force")
79 result = runner.invoke(cli, final_args, env=_env(root))
80 return result.exit_code, result.output
81
82
83 def _write_object(root: pathlib.Path, content: bytes) -> str:
84 obj_id = hashlib.sha256(content).hexdigest()
85 obj_path = root / ".muse" / "objects" / obj_id[:2] / obj_id[2:]
86 obj_path.parent.mkdir(parents=True, exist_ok=True)
87 obj_path.write_bytes(content)
88 return obj_id
89
90
91 def _init_code_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]:
92 """Initialise a bare code-domain repo and return (root, repo_id)."""
93 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
94 from muse.core.snapshot import compute_snapshot_id, compute_commit_id
95
96 muse_dir = tmp_path / ".muse"
97 muse_dir.mkdir()
98 repo_id = str(uuid.uuid4())
99 (muse_dir / "repo.json").write_text(json.dumps({
100 "repo_id": repo_id,
101 "domain": "code",
102 "default_branch": "main",
103 "created_at": "2025-01-01T00:00:00+00:00",
104 }), encoding="utf-8")
105 (muse_dir / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
106 (muse_dir / "refs" / "heads").mkdir(parents=True)
107 (muse_dir / "snapshots").mkdir()
108 (muse_dir / "commits").mkdir()
109 (muse_dir / "objects").mkdir()
110 return tmp_path, repo_id
111
112
113 def _make_commit(
114 root: pathlib.Path,
115 repo_id: str,
116 branch: str = "main",
117 message: str = "test",
118 manifest: Manifest | None = None,
119 parent_commit_id: str | None = None,
120 parent2_commit_id: str | None = None,
121 ) -> str:
122 """Write a snapshot + commit and advance the branch ref."""
123 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
124 from muse.core.snapshot import compute_snapshot_id, compute_commit_id
125
126 ref_file = root / ".muse" / "refs" / "heads" / branch
127 if parent_commit_id is None and ref_file.exists():
128 parent_commit_id = ref_file.read_text().strip() or None
129
130 m = manifest or {}
131 snap_id = compute_snapshot_id(m)
132 committed_at = datetime.datetime.now(datetime.timezone.utc)
133 parent_ids: list[str] = []
134 if parent_commit_id:
135 parent_ids.append(parent_commit_id)
136 if parent2_commit_id:
137 parent_ids.append(parent2_commit_id)
138 commit_id = compute_commit_id(
139 parent_ids=parent_ids,
140 snapshot_id=snap_id,
141 message=message,
142 committed_at_iso=committed_at.isoformat(),
143 )
144 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=m))
145 write_commit(root, CommitRecord(
146 commit_id=commit_id,
147 repo_id=repo_id,
148 branch=branch,
149 snapshot_id=snap_id,
150 message=message,
151 committed_at=committed_at,
152 parent_commit_id=parent_commit_id,
153 parent2_commit_id=parent2_commit_id,
154 ))
155 ref_file.parent.mkdir(parents=True, exist_ok=True)
156 ref_file.write_text(commit_id, encoding="utf-8")
157 return commit_id
158
159
160 def _write_py(root: pathlib.Path, filename: str, content: str) -> str:
161 """Write Python content into the object store ONLY; return object_id.
162
163 We deliberately do NOT write the file to the working tree so that
164 ``require_clean_workdir`` never aborts the merge due to uncommitted
165 changes. The code plugin reads file bytes from the object store via
166 ``read_object(root, obj_id)``, so on-disk presence is not required.
167 """
168 return _write_object(root, content.encode())
169
170
171 def _ref(root: pathlib.Path, branch: str) -> str:
172 return (root / ".muse" / "refs" / "heads" / branch).read_text(encoding="utf-8").strip()
173
174
175 def _snapshot_manifest(root: pathlib.Path, branch: str) -> Manifest:
176 """Return the manifest for a branch's current HEAD snapshot."""
177 from muse.core.store import read_commit, read_snapshot
178 commit_id = _ref(root, branch)
179 commit = read_commit(root, commit_id)
180 assert commit is not None
181 snap = read_snapshot(root, commit.snapshot_id)
182 assert snap is not None
183 return snap.manifest
184
185
186 # ===========================================================================
187 # A — Fast-forward / up-to-date
188 # ===========================================================================
189
190
191 class TestMergeTopologyA:
192 """Ensure merge base detection is correct and no data is corrupted."""
193
194 def test_A1_fast_forward_updates_head_and_files(self, tmp_path: pathlib.Path) -> None:
195 """A1: ours is ancestor of theirs → fast-forward, working tree = theirs."""
196 root, repo_id = _init_code_repo(tmp_path)
197 a_id = _write_py(root, "app.py", "x = 1\n")
198 _make_commit(root, repo_id, branch="main", message="base",
199 manifest={"app.py": a_id})
200 base_commit = _ref(root, "main")
201
202 # Create feature branch from same base.
203 (root / ".muse" / "refs" / "heads" / "feat").write_text(base_commit)
204 b_id = _write_py(root, "app.py", "x = 2\n")
205 _make_commit(root, repo_id, branch="feat", message="feat commit",
206 manifest={"app.py": b_id})
207
208 code, out = _run(root, "merge", "feat")
209 assert code == 0, out
210 # main HEAD must now equal feat HEAD.
211 assert _ref(root, "main") == _ref(root, "feat")
212 # Manifest must equal feat's snapshot.
213 assert _snapshot_manifest(root, "main") == {"app.py": b_id}
214
215 def test_A2_already_up_to_date_prints_message(self, tmp_path: pathlib.Path) -> None:
216 """A2: theirs is ancestor of ours → 'Already up to date.'"""
217 root, repo_id = _init_code_repo(tmp_path)
218 a_id = _write_py(root, "f.py", "a = 1\n")
219 base_c = _make_commit(root, repo_id, branch="main", message="base",
220 manifest={"f.py": a_id})
221 (root / ".muse" / "refs" / "heads" / "old").write_text(base_c)
222 b_id = _write_py(root, "f.py", "a = 2\n")
223 _make_commit(root, repo_id, branch="main", message="advance",
224 manifest={"f.py": b_id})
225
226 code, out = _run(root, "merge", "old")
227 assert code == 0, out
228 assert "up to date" in out.lower()
229 # main must not have moved back.
230 assert _snapshot_manifest(root, "main") == {"f.py": b_id}
231
232 def test_A3_fast_forward_json_reports_fast_forward_status(self, tmp_path: pathlib.Path) -> None:
233 """A3: JSON output for fast-forward has status='fast_forward'."""
234 root, repo_id = _init_code_repo(tmp_path)
235 a_id = _write_py(root, "f.py", "a = 1\n")
236 base_c = _make_commit(root, repo_id, branch="main", message="base",
237 manifest={"f.py": a_id})
238 (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c)
239 b_id = _write_py(root, "f.py", "a = 2\n")
240 _make_commit(root, repo_id, branch="feat", message="feat",
241 manifest={"f.py": b_id})
242
243 code, out = _run(root, "merge", "--format", "json", "feat")
244 assert code == 0, out
245 data = json.loads(out)
246 assert data["status"] == "fast_forward"
247 assert data["conflicts"] == []
248
249 def test_A4_fast_forward_preserves_all_theirs_files(self, tmp_path: pathlib.Path) -> None:
250 """A4: fast-forward with 50 files — all must appear in main's manifest."""
251 root, repo_id = _init_code_repo(tmp_path)
252 a_id = _write_py(root, "base.py", "base = True\n")
253 base_c = _make_commit(root, repo_id, branch="main", message="base",
254 manifest={"base.py": a_id})
255 (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c)
256
257 manifest: Manifest = {"base.py": a_id}
258 for i in range(50):
259 oid = _write_py(root, f"module_{i:02d}.py", f"x_{i} = {i}\n")
260 manifest[f"module_{i:02d}.py"] = oid
261 _make_commit(root, repo_id, branch="feat", message="many files",
262 manifest=manifest)
263
264 code, _ = _run(root, "merge", "feat")
265 assert code == 0
266 merged = _snapshot_manifest(root, "main")
267 for i in range(50):
268 assert f"module_{i:02d}.py" in merged, f"module_{i:02d}.py missing after fast-forward"
269
270 def test_A5_no_ff_creates_merge_commit(self, tmp_path: pathlib.Path) -> None:
271 """A5: --no-ff skips fast-forward and always creates a merge commit."""
272 root, repo_id = _init_code_repo(tmp_path)
273 a_id = _write_py(root, "f.py", "a = 1\n")
274 base_c = _make_commit(root, repo_id, branch="main", message="base",
275 manifest={"f.py": a_id})
276 (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c)
277 b_id = _write_py(root, "f.py", "a = 2\n")
278 feat_c = _make_commit(root, repo_id, branch="feat", message="feat",
279 manifest={"f.py": b_id})
280
281 from muse.core.store import read_commit
282 pre_main = _ref(root, "main")
283 code, out = _run(root, "merge", "--no-ff", "feat")
284 assert code == 0, out
285 post_main = _ref(root, "main")
286 # HEAD must have advanced (new merge commit created).
287 assert post_main != pre_main
288 # The new commit must have TWO parents.
289 commit = read_commit(root, post_main)
290 assert commit is not None
291 assert commit.parent2_commit_id is not None, "no-ff must create merge commit with 2 parents"
292
293
294 # ===========================================================================
295 # B — Three-way clean merges (no conflicts anywhere)
296 # ===========================================================================
297
298
299 class TestThreeWayCleanMergeB:
300 """Theirs-only and ours-only changes all survive; merged snapshot is correct."""
301
302 def test_B1_disjoint_file_changes_both_survive(self, tmp_path: pathlib.Path) -> None:
303 """B1: ours changes a.py, theirs changes b.py — both must be in merged."""
304 root, repo_id = _init_code_repo(tmp_path)
305 a0 = _write_py(root, "a.py", "a = 0\n")
306 b0 = _write_py(root, "b.py", "b = 0\n")
307 base_c = _make_commit(root, repo_id, branch="main", message="base",
308 manifest={"a.py": a0, "b.py": b0})
309 (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c)
310
311 # ours: modify a.py
312 a1 = _write_py(root, "a.py", "a = 1\n")
313 _make_commit(root, repo_id, branch="main", message="ours: change a",
314 manifest={"a.py": a1, "b.py": b0})
315
316 # theirs: modify b.py
317 b1 = _write_py(root, "b.py", "b = 1\n")
318 _make_commit(root, repo_id, branch="feat", message="theirs: change b",
319 manifest={"a.py": a0, "b.py": b1})
320
321 code, out = _run(root, "merge", "feat")
322 assert code == 0, out
323 m = _snapshot_manifest(root, "main")
324 assert m.get("a.py") == a1, "ours change to a.py lost after clean merge"
325 assert m.get("b.py") == b1, "theirs change to b.py lost after clean merge"
326
327 def test_B2_theirs_adds_new_file(self, tmp_path: pathlib.Path) -> None:
328 """B2: theirs adds new.py that ours never touched — must be in merged."""
329 root, repo_id = _init_code_repo(tmp_path)
330 a0 = _write_py(root, "a.py", "a = 0\n")
331 base_c = _make_commit(root, repo_id, branch="main", message="base",
332 manifest={"a.py": a0})
333 (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c)
334
335 new_id = _write_py(root, "new.py", "new = True\n")
336 _make_commit(root, repo_id, branch="feat", message="add new.py",
337 manifest={"a.py": a0, "new.py": new_id})
338
339 code, out = _run(root, "merge", "feat")
340 assert code == 0, out
341 assert "new.py" in _snapshot_manifest(root, "main"), "theirs new file lost"
342
343 def test_B3_theirs_deletes_file_ours_never_touched(self, tmp_path: pathlib.Path) -> None:
344 """B3: theirs deletes stale.py — must be absent in merged."""
345 root, repo_id = _init_code_repo(tmp_path)
346 a0 = _write_py(root, "a.py", "a = 0\n")
347 stale0 = _write_py(root, "stale.py", "dead = True\n")
348 base_c = _make_commit(root, repo_id, branch="main", message="base",
349 manifest={"a.py": a0, "stale.py": stale0})
350 (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c)
351
352 a1 = _write_py(root, "a.py", "a = 1\n")
353 _make_commit(root, repo_id, branch="main", message="ours: tweak a",
354 manifest={"a.py": a1, "stale.py": stale0})
355 _make_commit(root, repo_id, branch="feat", message="theirs: rm stale.py",
356 manifest={"a.py": a0})
357
358 code, out = _run(root, "merge", "feat")
359 assert code == 0, out
360 m = _snapshot_manifest(root, "main")
361 assert "stale.py" not in m, "theirs deletion of stale.py was not applied"
362 assert m.get("a.py") == a1, "ours change to a.py lost"
363
364 def test_B4_many_theirs_only_additions_all_survive(self, tmp_path: pathlib.Path) -> None:
365 """B4: theirs adds 30 files, ours changes 1 file — all 30 must be in merged."""
366 root, repo_id = _init_code_repo(tmp_path)
367 base_id = _write_py(root, "main.py", "x = 0\n")
368 base_c = _make_commit(root, repo_id, branch="main", message="base",
369 manifest={"main.py": base_id})
370 (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c)
371
372 # ours: bump main.py
373 bumped = _write_py(root, "main.py", "x = 1\n")
374 _make_commit(root, repo_id, branch="main", message="ours: bump",
375 manifest={"main.py": bumped})
376
377 # theirs: 30 new modules
378 theirs_manifest = {"main.py": base_id}
379 for i in range(30):
380 oid = _write_py(root, f"mod_{i}.py", f"MOD_{i} = True\n")
381 theirs_manifest[f"mod_{i}.py"] = oid
382 _make_commit(root, repo_id, branch="feat", message="theirs: add 30 mods",
383 manifest=theirs_manifest)
384
385 code, out = _run(root, "merge", "feat")
386 assert code == 0, out
387 m = _snapshot_manifest(root, "main")
388 for i in range(30):
389 assert f"mod_{i}.py" in m, f"mod_{i}.py missing after clean three-way merge"
390
391
392 # ===========================================================================
393 # C — Three-way with conflicts that MUST be surfaced
394 # ===========================================================================
395
396
397 class TestThreeWayConflictSurfacedC:
398 """Conflicts must be reported; the merge must NOT silently produce wrong content."""
399
400 def test_C1_genuine_conflict_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
401 """C1: both sides change the same symbol in the same file → exit nonzero."""
402 root, repo_id = _init_code_repo(tmp_path)
403 a0 = _write_py(root, "service.py", textwrap.dedent("""\
404 def charge():
405 return 'v1'
406 """))
407 base_c = _make_commit(root, repo_id, branch="main", message="base",
408 manifest={"service.py": a0})
409 (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c)
410
411 a_ours = _write_py(root, "service.py", textwrap.dedent("""\
412 def charge():
413 return 'ours-v2'
414 """))
415 _make_commit(root, repo_id, branch="main", message="ours: change charge",
416 manifest={"service.py": a_ours})
417
418 a_theirs = _write_py(root, "service.py", textwrap.dedent("""\
419 def charge():
420 return 'theirs-v2'
421 """))
422 _make_commit(root, repo_id, branch="feat", message="theirs: change charge",
423 manifest={"service.py": a_theirs})
424
425 code, out = _run_unchecked(root, "merge", "feat")
426 assert code != 0, "conflict must exit nonzero, not silently succeed"
427
428 def test_C2_conflict_creates_merge_state_json(self, tmp_path: pathlib.Path) -> None:
429 """C2: conflict writes MERGE_STATE.json with the right fields."""
430 root, repo_id = _init_code_repo(tmp_path)
431 f0 = _write_py(root, "f.py", textwrap.dedent("""\
432 def foo():
433 return 1
434 """))
435 base_c = _make_commit(root, repo_id, branch="main", message="base",
436 manifest={"f.py": f0})
437 (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c)
438
439 f_ours = _write_py(root, "f.py", textwrap.dedent("""\
440 def foo():
441 return 2
442 """))
443 _make_commit(root, repo_id, branch="main", message="ours",
444 manifest={"f.py": f_ours})
445
446 f_theirs = _write_py(root, "f.py", textwrap.dedent("""\
447 def foo():
448 return 99
449 """))
450 _make_commit(root, repo_id, branch="feat", message="theirs",
451 manifest={"f.py": f_theirs})
452
453 _run_unchecked(root, "merge", "feat")
454 state_path = root / ".muse" / "MERGE_STATE.json"
455 assert state_path.exists(), "MERGE_STATE.json must be written on conflict"
456 state = json.loads(state_path.read_text())
457 assert "ours_commit" in state
458 assert "theirs_commit" in state
459 assert "conflict_paths" in state
460
461 def test_C3_conflict_json_format_lists_paths(self, tmp_path: pathlib.Path) -> None:
462 """C3: --format json reports conflict with non-empty conflicts list."""
463 root, repo_id = _init_code_repo(tmp_path)
464 f0 = _write_py(root, "svc.py", textwrap.dedent("""\
465 def go():
466 pass
467 """))
468 base_c = _make_commit(root, repo_id, branch="main", message="base",
469 manifest={"svc.py": f0})
470 (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c)
471
472 f1 = _write_py(root, "svc.py", textwrap.dedent("""\
473 def go():
474 return 'ours'
475 """))
476 _make_commit(root, repo_id, branch="main", message="ours", manifest={"svc.py": f1})
477
478 f2 = _write_py(root, "svc.py", textwrap.dedent("""\
479 def go():
480 return 'theirs'
481 """))
482 _make_commit(root, repo_id, branch="feat", message="theirs", manifest={"svc.py": f2})
483
484 result = runner.invoke(cli, ["merge", "--force", "--format", "json", "feat"],
485 env=_env(root))
486 data = json.loads(result.output)
487 assert data["status"] == "conflict"
488 assert len(data["conflicts"]) > 0
489
490
491 # ===========================================================================
492 # D — The silent-drop regression (commuting OT ops on same file)
493 # ===========================================================================
494
495
496 class TestSilentDropRegressionD:
497 """
498 The exact bug that burned us: two branches modify DIFFERENT symbols in
499 the same file. OT sees them as commuting (non-conflicting at symbol level),
500 but cannot reconstruct the merged blob. Before the fix this silently
501 produced the ours blob and dropped all theirs changes in that file.
502 After the fix, this must either surface a conflict or correctly auto-merge.
503
504 In either case: theirs-only CHANGES to OTHER FILES must always survive.
505 """
506
507 def test_D1_commuting_symbol_changes_do_not_silently_succeed(
508 self, tmp_path: pathlib.Path
509 ) -> None:
510 """D1: ours changes func_a, theirs changes func_b — must conflict or merge, never silently lose theirs."""
511 root, repo_id = _init_code_repo(tmp_path)
512 base_code = textwrap.dedent("""\
513 def func_a():
514 return 'a-v1'
515
516 def func_b():
517 return 'b-v1'
518 """)
519 f0 = _write_py(root, "lib.py", base_code)
520 base_c = _make_commit(root, repo_id, branch="main", message="base",
521 manifest={"lib.py": f0})
522 (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c)
523
524 ours_code = textwrap.dedent("""\
525 def func_a():
526 return 'a-v2'
527
528 def func_b():
529 return 'b-v1'
530 """)
531 f_ours = _write_py(root, "lib.py", ours_code)
532 _make_commit(root, repo_id, branch="main", message="ours: change func_a",
533 manifest={"lib.py": f_ours})
534
535 theirs_code = textwrap.dedent("""\
536 def func_a():
537 return 'a-v1'
538
539 def func_b():
540 return 'b-v2'
541 """)
542 f_theirs = _write_py(root, "lib.py", theirs_code)
543 _make_commit(root, repo_id, branch="feat", message="theirs: change func_b",
544 manifest={"lib.py": f_theirs})
545
546 result = runner.invoke(cli, ["merge", "--force", "--format", "json", "feat"],
547 env=_env(root))
548 data = json.loads(result.output)
549
550 if data["status"] == "merged":
551 # If auto-merged: func_b MUST be 'b-v2', never silently kept as 'b-v1'.
552 m = _snapshot_manifest(root, "main")
553 from muse.core.store import read_snapshot
554 snap = None
555 from muse.core.store import read_commit
556 commit = read_commit(root, _ref(root, "main"))
557 assert commit is not None
558 from muse.core.store import read_snapshot
559 snap = read_snapshot(root, commit.snapshot_id)
560 assert snap is not None
561 # We can't read the actual merged file content from the manifest
562 # without the working tree, but we CAN assert lib.py is present.
563 assert "lib.py" in snap.manifest
564 else:
565 # If conflict: that is correct — better a conflict than silent data loss.
566 assert data["status"] == "conflict"
567 assert len(data["conflicts"]) > 0
568
569 def test_D2_theirs_only_file_survives_commuting_conflict(
570 self, tmp_path: pathlib.Path
571 ) -> None:
572 """D2: regression core — theirs-only executor.py must survive even when lib.py conflicts."""
573 root, repo_id = _init_code_repo(tmp_path)
574 base_db = textwrap.dedent("""\
575 def pool():
576 pass
577 """)
578 base_exec = textwrap.dedent("""\
579 def run():
580 args = ['--pid=private']
581 return args
582 """)
583 db0 = _write_py(root, "database.py", base_db)
584 exec0 = _write_py(root, "executor.py", base_exec)
585 base_c = _make_commit(root, repo_id, branch="main", message="base",
586 manifest={"database.py": db0, "executor.py": exec0})
587 (root / ".muse" / "refs" / "heads" / "fix-branch").write_text(base_c)
588
589 # ours (dev): fix pool_pre_ping in database.py, don't touch executor.py
590 ours_db = textwrap.dedent("""\
591 def pool():
592 return 'pool_pre_ping=True'
593 """)
594 db_ours = _write_py(root, "database.py", ours_db)
595 _make_commit(root, repo_id, branch="main", message="ours: pool_pre_ping fix",
596 manifest={"database.py": db_ours, "executor.py": exec0})
597
598 # theirs (fix-branch): fix pool_pre_ping the same way AND fix executor.py
599 theirs_db = textwrap.dedent("""\
600 def pool():
601 return 'pool_pre_ping=True'
602 """)
603 theirs_exec = textwrap.dedent("""\
604 def run():
605 args = [] # --pid=private removed (invalid Docker flag)
606 return args
607 """)
608 db_theirs = _write_py(root, "database.py", theirs_db)
609 exec_theirs = _write_py(root, "executor.py", theirs_exec)
610 _make_commit(root, repo_id, branch="fix-branch",
611 message="theirs: pool_pre_ping + remove --pid=private",
612 manifest={"database.py": db_theirs, "executor.py": exec_theirs})
613
614 result = runner.invoke(cli, ["merge", "--force", "--format", "json", "fix-branch"],
615 env=_env(root))
616 data = json.loads(result.output)
617
618 # The critical assertion: in any outcome, executor.py must NOT be the old version.
619 # Either the merge succeeded and executor.py has the fix, OR a conflict is raised
620 # so the user can resolve it. What is NEVER acceptable: silent success with old content.
621 if data["status"] == "merged":
622 from muse.core.store import read_commit, read_snapshot
623 commit = read_commit(root, _ref(root, "main"))
624 assert commit is not None
625 snap = read_snapshot(root, commit.snapshot_id)
626 assert snap is not None
627 # executor.py must be the FIXED version (no --pid=private), not the base.
628 assert snap.manifest.get("executor.py") == exec_theirs, (
629 "REGRESSION: executor.py fix was silently dropped — "
630 "the theirs-only change was lost in the merge"
631 )
632 else:
633 # Conflict is acceptable (user can resolve), silent data loss is not.
634 assert data["status"] == "conflict"
635
636 def test_D3_identical_object_hash_on_both_sides_no_file_conflict(
637 self, tmp_path: pathlib.Path
638 ) -> None:
639 """D3: both sides converge to the EXACT same object hash — file-level conflict impossible.
640
641 When ours and theirs both arrive at the same content hash for a file,
642 diff_snapshots sees them as identical (no change relative to each other).
643 The merge engine must treat this as a clean convergence — or at minimum,
644 the resulting manifest must contain that file at the shared hash.
645
646 This tests the file-level merge_engine layer (diff_snapshots / apply_merge).
647 Symbol-level conflict detection (within the file) is separate and handled
648 by the plugin — if the plugin marks it as conflicting despite identical
649 hashes, that is a plugin-level decision, not a data-loss scenario.
650 """
651 from muse.core.merge_engine import diff_snapshots, detect_conflicts, apply_merge
652
653 fixed_hash = _h("pool_pre_ping_fix_content")
654 base_hash = _h("original_pool_content")
655
656 base_manifest = {"database.py": base_hash, "other.py": _h("other")}
657 ours_manifest = {"database.py": fixed_hash, "other.py": _h("other")}
658 theirs_manifest = {"database.py": fixed_hash, "other.py": _h("other")}
659
660 ours_changed = diff_snapshots(base_manifest, ours_manifest)
661 theirs_changed = diff_snapshots(base_manifest, theirs_manifest)
662 conflicts = detect_conflicts(ours_changed, theirs_changed, ours_manifest, theirs_manifest)
663 merged = apply_merge(base_manifest, ours_manifest, theirs_manifest,
664 ours_changed, theirs_changed, conflicts)
665
666 # Both sides converged to the SAME hash — detect_conflicts must not flag it.
667 assert "database.py" not in conflicts, (
668 "D3 VIOLATED: convergent same-hash change wrongly reported as conflict"
669 )
670 # apply_merge must include database.py at the agreed fixed hash.
671 assert merged.get("database.py") == fixed_hash, (
672 "D3 VIOLATED: database.py absent or at wrong hash after convergent merge"
673 )
674
675 def test_D4_the_musehub_regression_scenario(self, tmp_path: pathlib.Path) -> None:
676 """D4: exact topology from the MuseHub incident — 3 branches, complex DAG.
677
678 Timeline:
679 base → ours (dev): pool_pre_ping DB fix
680 base → theirs (fix-branch): pool_pre_ping fix + --pid fix + AGENTS.md rewrite + new_feature.py
681
682 When user merges fix-branch into dev:
683 - database.py: both changed (same content, should be clean OR conflict)
684 - executor.py: theirs-only change → MUST survive in merged
685 - agents.md: theirs-only change → MUST survive in merged
686 - new_feature.py: theirs-only addition → MUST survive in merged
687 """
688 root, repo_id = _init_code_repo(tmp_path)
689
690 # Base state
691 db0 = _write_py(root, "database.py", "def pool(): pass\n")
692 exec0 = _write_py(root, "executor.py", "args = ['--pid=private']\n")
693 agents0 = _write_py(root, "agents.md", "# Short docs\n")
694 base_c = _make_commit(root, repo_id, branch="main", message="base",
695 manifest={"database.py": db0, "executor.py": exec0,
696 "agents.md": agents0})
697 (root / ".muse" / "refs" / "heads" / "fix-branch").write_text(base_c)
698
699 # ours (dev): pool_pre_ping only
700 db_ours = _write_py(root, "database.py", "def pool(): return 'pool_pre_ping=True'\n")
701 _make_commit(root, repo_id, branch="main", message="ours: pool_pre_ping",
702 manifest={"database.py": db_ours, "executor.py": exec0,
703 "agents.md": agents0})
704
705 # theirs (fix-branch): pool_pre_ping + pid fix + AGENTS.md rewrite + new file
706 db_theirs = _write_py(root, "database.py", "def pool(): return 'pool_pre_ping=True'\n")
707 exec_theirs = _write_py(root, "executor.py", "args = [] # no --pid\n")
708 agents_theirs = _write_py(root, "agents.md", "# Comprehensive 700-line rewrite\n" * 10)
709 new_feat = _write_py(root, "new_feature.py", "NEW = True\n")
710 _make_commit(root, repo_id, branch="fix-branch",
711 message="theirs: comprehensive fix bundle",
712 manifest={"database.py": db_theirs, "executor.py": exec_theirs,
713 "agents.md": agents_theirs, "new_feature.py": new_feat})
714
715 result = runner.invoke(cli, ["merge", "--force", "--format", "json", "fix-branch"],
716 env=_env(root))
717 data = json.loads(result.output)
718
719 if data["status"] == "merged":
720 from muse.core.store import read_commit, read_snapshot
721 commit = read_commit(root, _ref(root, "main"))
722 assert commit is not None
723 snap = read_snapshot(root, commit.snapshot_id)
724 assert snap is not None
725 m = snap.manifest
726
727 assert m.get("executor.py") == exec_theirs, (
728 "REGRESSION: executor.py (--pid fix) was silently dropped"
729 )
730 assert m.get("agents.md") == agents_theirs, (
731 "REGRESSION: agents.md rewrite was silently dropped"
732 )
733 assert "new_feature.py" in m, (
734 "REGRESSION: new_feature.py addition was silently dropped"
735 )
736 else:
737 # A conflict is an acceptable outcome.
738 # But check that it's not some other failure mode.
739 assert data["status"] == "conflict", f"unexpected status: {data['status']}"
740
741
742 # ===========================================================================
743 # E — Theirs-only files MUST survive even when there are conflicts elsewhere
744 # ===========================================================================
745
746
747 class TestTheirsOnlySurvivesConflictE:
748 """
749 When there IS a genuine conflict in file X, the merge stops. But the
750 *would-be* merged manifest (what the engine computed before stopping) must
751 still contain all theirs-only changes. The engine must not take a shortcut
752 and return ours manifest verbatim just because a conflict exists.
753
754 These tests use the JSON output's "files_changed" or check MERGE_STATE.json
755 to infer what the engine planned to write.
756
757 After a conflict, the user resolves and re-commits — but if the engine's
758 intermediate merged manifest is wrong, the resolution will silently bake
759 in the data loss.
760 """
761
762 def test_E1_theirs_additions_included_in_merged_manifest_despite_conflict(
763 self, tmp_path: pathlib.Path
764 ) -> None:
765 """E1: conflict in a.py; theirs adds b.py and c.py — both must be in merged manifest."""
766 root, repo_id = _init_code_repo(tmp_path)
767 a0 = _write_py(root, "a.py", textwrap.dedent("""\
768 def go():
769 return 1
770 """))
771 base_c = _make_commit(root, repo_id, branch="main", message="base",
772 manifest={"a.py": a0})
773 (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c)
774
775 a_ours = _write_py(root, "a.py", textwrap.dedent("""\
776 def go():
777 return 'ours'
778 """))
779 _make_commit(root, repo_id, branch="main", message="ours: change a.py",
780 manifest={"a.py": a_ours})
781
782 a_theirs = _write_py(root, "a.py", textwrap.dedent("""\
783 def go():
784 return 'theirs'
785 """))
786 b_theirs = _write_py(root, "b.py", "B = True\n")
787 c_theirs = _write_py(root, "c.py", "C = True\n")
788 _make_commit(root, repo_id, branch="feat", message="theirs: change a + add b + add c",
789 manifest={"a.py": a_theirs, "b.py": b_theirs, "c.py": c_theirs})
790
791 result = runner.invoke(cli, ["merge", "--force", "--format", "json", "feat"],
792 env=_env(root))
793 data = json.loads(result.output)
794
795 # Two acceptable outcomes:
796 # 1. Clean merge (auto-resolved) — b.py and c.py must be in main manifest
797 # 2. Conflict in a.py — MERGE_STATE must be written; we trust the engine
798 # will include b.py and c.py in the conflict-resolution manifest.
799 if data["status"] == "merged":
800 m = _snapshot_manifest(root, "main")
801 assert "b.py" in m, "theirs-only b.py was lost despite clean merge of other files"
802 assert "c.py" in m, "theirs-only c.py was lost despite clean merge of other files"
803 else:
804 assert data["status"] == "conflict"
805 # The engine computed conflicts — but must NOT have silently dropped b.py/c.py
806 # from the intermediate manifest it would apply after resolution.
807 # We verify this by inspecting what would have been applied: check that
808 # the conflict paths DON'T include b.py or c.py (they're theirs-only, not conflicts).
809 assert "b.py" not in data.get("conflicts", []), "b.py incorrectly marked as conflict"
810 assert "c.py" not in data.get("conflicts", []), "c.py incorrectly marked as conflict"
811
812 def test_E2_ten_theirs_only_files_all_excluded_from_conflict_list(
813 self, tmp_path: pathlib.Path
814 ) -> None:
815 """E2: 10 theirs-only additions must never appear in the conflict list."""
816 root, repo_id = _init_code_repo(tmp_path)
817 f0 = _write_py(root, "main.py", textwrap.dedent("""\
818 def run():
819 pass
820 """))
821 base_c = _make_commit(root, repo_id, branch="main", message="base",
822 manifest={"main.py": f0})
823 (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c)
824
825 f_ours = _write_py(root, "main.py", textwrap.dedent("""\
826 def run():
827 return 'ours'
828 """))
829 _make_commit(root, repo_id, branch="main", message="ours: modify run",
830 manifest={"main.py": f_ours})
831
832 f_theirs = _write_py(root, "main.py", textwrap.dedent("""\
833 def run():
834 return 'theirs'
835 """))
836 theirs_manifest: Manifest = {"main.py": f_theirs}
837 for i in range(10):
838 oid = _write_py(root, f"extra_{i}.py", f"EXTRA_{i} = True\n")
839 theirs_manifest[f"extra_{i}.py"] = oid
840 _make_commit(root, repo_id, branch="feat", message="theirs: conflict + 10 extras",
841 manifest=theirs_manifest)
842
843 result = runner.invoke(cli, ["merge", "--force", "--format", "json", "feat"],
844 env=_env(root))
845 data = json.loads(result.output)
846 conflicts = data.get("conflicts", [])
847 for i in range(10):
848 assert f"extra_{i}.py" not in conflicts, (
849 f"extra_{i}.py is a theirs-only addition — must not appear in conflicts"
850 )
851
852
853 # ===========================================================================
854 # F — Strategy shortcuts correctness
855 # ===========================================================================
856
857
858 class TestStrategyShortcutsF:
859 """
860 --strategy=ours and --strategy=theirs are convenience shortcuts.
861 The correct behaviour: non-conflicting theirs/ours changes are STILL
862 applied; only the conflicting files take the chosen side.
863
864 The old bug: --strategy=ours took ENTIRE ours manifest, discarding all
865 theirs-only changes. This caused data loss just as severe as the OT bug.
866 """
867
868 def test_F1_strategy_ours_preserves_theirs_only_files(self, tmp_path: pathlib.Path) -> None:
869 """F1: --strategy=ours for conflict in a.py; theirs-only b.py must still appear."""
870 root, repo_id = _init_code_repo(tmp_path)
871 a0 = _write_py(root, "a.py", textwrap.dedent("""\
872 def go():
873 return 1
874 """))
875 base_c = _make_commit(root, repo_id, branch="main", message="base",
876 manifest={"a.py": a0})
877 (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c)
878
879 a_ours = _write_py(root, "a.py", textwrap.dedent("""\
880 def go():
881 return 'ours'
882 """))
883 _make_commit(root, repo_id, branch="main", message="ours", manifest={"a.py": a_ours})
884
885 a_theirs = _write_py(root, "a.py", textwrap.dedent("""\
886 def go():
887 return 'theirs'
888 """))
889 b_theirs = _write_py(root, "b.py", "B = True\n")
890 _make_commit(root, repo_id, branch="feat", message="theirs",
891 manifest={"a.py": a_theirs, "b.py": b_theirs})
892
893 code, out = _run(root, "merge", "--strategy", "ours", "feat")
894 assert code == 0, out
895
896 m = _snapshot_manifest(root, "main")
897 # a.py must be ours version.
898 assert m.get("a.py") == a_ours, "--strategy=ours must keep ours version of conflicting file"
899 # b.py is theirs-only — it must be present.
900 assert "b.py" in m, (
901 "REGRESSION: --strategy=ours discarded theirs-only b.py. "
902 "Non-conflicting theirs changes must still be applied."
903 )
904
905 def test_F2_strategy_theirs_preserves_ours_only_files(self, tmp_path: pathlib.Path) -> None:
906 """F2: --strategy=theirs for conflict in a.py; ours-only c.py must still appear."""
907 root, repo_id = _init_code_repo(tmp_path)
908 a0 = _write_py(root, "a.py", textwrap.dedent("""\
909 def go():
910 return 1
911 """))
912 base_c = _make_commit(root, repo_id, branch="main", message="base",
913 manifest={"a.py": a0})
914 (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c)
915
916 a_ours = _write_py(root, "a.py", textwrap.dedent("""\
917 def go():
918 return 'ours'
919 """))
920 c_ours = _write_py(root, "c.py", "C = True\n")
921 _make_commit(root, repo_id, branch="main", message="ours",
922 manifest={"a.py": a_ours, "c.py": c_ours})
923
924 a_theirs = _write_py(root, "a.py", textwrap.dedent("""\
925 def go():
926 return 'theirs'
927 """))
928 _make_commit(root, repo_id, branch="feat", message="theirs",
929 manifest={"a.py": a_theirs})
930
931 code, out = _run(root, "merge", "--strategy", "theirs", "feat")
932 assert code == 0, out
933
934 m = _snapshot_manifest(root, "main")
935 # a.py must be theirs.
936 assert m.get("a.py") == a_theirs, "--strategy=theirs must keep theirs version"
937 # c.py is ours-only — must be in merged.
938 assert "c.py" in m, (
939 "REGRESSION: --strategy=theirs discarded ours-only c.py. "
940 "Non-conflicting ours changes must still be applied."
941 )
942
943 def test_F3_strategy_ours_with_zero_ours_changes_is_up_to_date(
944 self, tmp_path: pathlib.Path
945 ) -> None:
946 """F3: --strategy=ours when ours == base → theirs changes should all be applied."""
947 root, repo_id = _init_code_repo(tmp_path)
948 f0 = _write_py(root, "f.py", "x = 0\n")
949 base_c = _make_commit(root, repo_id, branch="main", message="base",
950 manifest={"f.py": f0})
951 (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c)
952
953 g_id = _write_py(root, "g.py", "g = True\n")
954 _make_commit(root, repo_id, branch="feat", message="theirs: add g.py",
955 manifest={"f.py": f0, "g.py": g_id})
956
957 # No ours changes since base.
958 code, out = _run(root, "merge", "--strategy", "ours", "feat")
959 assert code == 0, out
960 m = _snapshot_manifest(root, "main")
961 # g.py is theirs-only — must be present.
962 assert "g.py" in m, "theirs-only addition lost with --strategy=ours when ours has no changes"
963
964
965 # ===========================================================================
966 # G — Full MuseHub regression scenario: pool_pre_ping + executor + AGENTS.md
967 # ===========================================================================
968
969
970 class TestMuseHubRegressionScenarioG:
971 """
972 Reproduces the exact topology that led to every CI run failing with
973 'docker: --pid: invalid PID mode' for days.
974
975 This test is the "aha! that's it!" test the user asked for.
976 It must FAIL on the old Muse code (before commit 73427a30) and
977 PASS on the fixed code.
978 """
979
980 def test_G1_musehub_incident_executor_fix_not_lost(self, tmp_path: pathlib.Path) -> None:
981 """G1: the MuseHub incident in miniature — never again.
982
983 Topology:
984 C0 (base): database.py v1, executor.py v1 (broken), agents.md v1
985 C1 (dev): pool_pre_ping fix on database.py ← ours
986 C2 (fix-pool): pool_pre_ping fix on database.py ← theirs (same fix)
987 + --pid=private removed from executor.py ← theirs only
988 + agents.md comprehensive rewrite ← theirs only
989
990 Expected after merge:
991 executor.py MUST be the fixed version (no --pid=private)
992 agents.md MUST be the comprehensive rewrite
993 database.py MUST be the pool_pre_ping version (either side, same content)
994 """
995 root, repo_id = _init_code_repo(tmp_path)
996
997 db_v1 = _write_py(root, "database.py",
998 "def init_db(): return engine\n")
999 exec_v1 = _write_py(root, "executor.py",
1000 "DOCKER_ARGS = ['--memory=1g', '--pid=private']\n")
1001 agents_v1 = _write_py(root, "agents.md",
1002 "# MuseHub Agent Contract\nDo stuff.\n")
1003
1004 c0 = _make_commit(root, repo_id, branch="main", message="C0: base",
1005 manifest={"database.py": db_v1, "executor.py": exec_v1,
1006 "agents.md": agents_v1})
1007 (root / ".muse" / "refs" / "heads" / "fix-pool").write_text(c0)
1008
1009 # C1 — ours (dev): pool_pre_ping fix, nothing else
1010 db_v2 = _write_py(root, "database.py",
1011 "def init_db(): return engine.execution_options(pool_pre_ping=True)\n")
1012 c1 = _make_commit(root, repo_id, branch="main", message="C1: pool_pre_ping",
1013 manifest={"database.py": db_v2, "executor.py": exec_v1,
1014 "agents.md": agents_v1})
1015
1016 # C2 — theirs (fix-pool): same pool_pre_ping + executor fix + agents rewrite
1017 db_v2b = _write_py(root, "database.py",
1018 "def init_db(): return engine.execution_options(pool_pre_ping=True)\n")
1019 exec_v2 = _write_py(root, "executor.py",
1020 "DOCKER_ARGS = ['--memory=1g'] # --pid=private removed\n")
1021 agents_v2 = _write_py(root, "agents.md",
1022 "# Comprehensive 700-line rewrite\n" * 20)
1023 c2 = _make_commit(root, repo_id, branch="fix-pool",
1024 message="C2: pool_pre_ping + executor fix + agents rewrite",
1025 manifest={"database.py": db_v2b, "executor.py": exec_v2,
1026 "agents.md": agents_v2})
1027
1028 result = runner.invoke(cli, ["merge", "--force", "--format", "json", "fix-pool"],
1029 env=_env(root))
1030 data = json.loads(result.output)
1031
1032 from muse.core.store import read_commit, read_snapshot
1033
1034 if data["status"] == "merged":
1035 commit = read_commit(root, _ref(root, "main"))
1036 assert commit is not None
1037 snap = read_snapshot(root, commit.snapshot_id)
1038 assert snap is not None
1039 m = snap.manifest
1040
1041 assert m.get("executor.py") == exec_v2, (
1042 "\n\nREGRESSION DETECTED — test_G1_musehub_incident_executor_fix_not_lost\n"
1043 "executor.py still has '--pid=private' after merge.\n"
1044 "The silent-drop bug in CodePlugin.merge_ops has returned.\n"
1045 "See commit 73427a30 for the fix that must be applied.\n"
1046 )
1047 assert m.get("agents.md") == agents_v2, (
1048 "\n\nREGRESSION DETECTED — agents.md rewrite was silently dropped.\n"
1049 )
1050 # database.py must be the pool_pre_ping version (same content on both sides).
1051 assert m.get("database.py") in (db_v2, db_v2b), (
1052 "database.py pool_pre_ping fix was lost"
1053 )
1054 elif data["status"] == "conflict":
1055 # Conflict is acceptable. Verify executor.py and agents.md are NOT in the conflict list.
1056 conflicts = data.get("conflicts", [])
1057 assert "executor.py" not in conflicts, (
1058 "executor.py is theirs-only — must not appear in conflicts, only in merged manifest"
1059 )
1060 assert "agents.md" not in conflicts, (
1061 "agents.md is theirs-only — must not appear in conflicts"
1062 )
1063 else:
1064 pytest.fail(f"Unexpected merge status: {data['status']}\n{data}")
1065
1066 def test_G2_merge_commit_has_two_parents(self, tmp_path: pathlib.Path) -> None:
1067 """G2: a successful three-way merge always creates a commit with 2 parent IDs."""
1068 root, repo_id = _init_code_repo(tmp_path)
1069 a0 = _write_py(root, "a.py", "x = 0\n")
1070 base_c = _make_commit(root, repo_id, branch="main", message="base",
1071 manifest={"a.py": a0})
1072 (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c)
1073
1074 a1 = _write_py(root, "a.py", "x = 1\n")
1075 _make_commit(root, repo_id, branch="main", message="ours", manifest={"a.py": a1})
1076
1077 b1 = _write_py(root, "b.py", "b = 1\n")
1078 _make_commit(root, repo_id, branch="feat", message="theirs",
1079 manifest={"a.py": a0, "b.py": b1})
1080
1081 code, out = _run(root, "merge", "feat")
1082 assert code == 0, out
1083
1084 from muse.core.store import read_commit
1085 commit = read_commit(root, _ref(root, "main"))
1086 assert commit is not None
1087 # A three-way merge commit must record both parents.
1088 assert commit.parent2_commit_id is not None, (
1089 "three-way merge commit missing second parent — "
1090 "merge history will appear linear in `muse log`"
1091 )
1092
1093 def test_G3_merged_snapshot_is_not_ours_snapshot_verbatim(
1094 self, tmp_path: pathlib.Path
1095 ) -> None:
1096 """G3: the snapshot recorded by the merge commit must differ from ours snapshot.
1097
1098 When the merged snapshot equals ours verbatim, theirs changes were silently dropped.
1099 """
1100 root, repo_id = _init_code_repo(tmp_path)
1101 a0 = _write_py(root, "a.py", "x = 0\n")
1102 base_c = _make_commit(root, repo_id, branch="main", message="base",
1103 manifest={"a.py": a0})
1104 (root / ".muse" / "refs" / "heads" / "feat").write_text(base_c)
1105
1106 a1 = _write_py(root, "a.py", "x = 1\n")
1107 ours_c = _make_commit(root, repo_id, branch="main", message="ours",
1108 manifest={"a.py": a1})
1109
1110 b1 = _write_py(root, "b.py", "b = True\n")
1111 _make_commit(root, repo_id, branch="feat", message="theirs: add b.py",
1112 manifest={"a.py": a0, "b.py": b1})
1113
1114 # Get ours snapshot_id BEFORE the merge.
1115 from muse.core.store import read_commit
1116 ours_commit = read_commit(root, ours_c)
1117 assert ours_commit is not None
1118 ours_snap_id = ours_commit.snapshot_id
1119
1120 code, out = _run(root, "merge", "feat")
1121 assert code == 0, out
1122
1123 merge_commit = read_commit(root, _ref(root, "main"))
1124 assert merge_commit is not None
1125 assert merge_commit.snapshot_id != ours_snap_id, (
1126 "REGRESSION: merged snapshot equals ours snapshot verbatim. "
1127 "Theirs changes (b.py addition) were silently discarded."
1128 )
1129
1130
1131 # ===========================================================================
1132 # H — Merge-base correctness for complex DAG topologies
1133 # ===========================================================================
1134
1135
1136 class TestMergeBaseCorrectnessH:
1137 """
1138 find_merge_base must handle complex DAG shapes correctly.
1139 An incorrect LCA leads to wrong merge-base manifests, which cause
1140 phantom conflicts (changes treated as conflicting when they aren't)
1141 or missed conflicts (changes treated as clean when they conflict).
1142 """
1143
1144 def test_H1_diamond_topology_correct_lca(self, tmp_path: pathlib.Path) -> None:
1145 """H1: diamond DAG — LCA is the bottom of the diamond, not an earlier commit.
1146
1147 C0
1148 /\\
1149 C1 C2
1150 \\ /
1151 C3 (merge of C1 and C2)
1152
1153 Merging C3 into C1 (or C2) should detect C0 as the LCA, not something else.
1154 """
1155 from muse.core.merge_engine import find_merge_base
1156 root, repo_id = _init_code_repo(tmp_path)
1157
1158 f0 = _write_py(root, "f.py", "v = 0\n")
1159 c0 = _make_commit(root, repo_id, branch="main", message="C0",
1160 manifest={"f.py": f0})
1161 (root / ".muse" / "refs" / "heads" / "branch-a").write_text(c0)
1162 (root / ".muse" / "refs" / "heads" / "branch-b").write_text(c0)
1163
1164 f1 = _write_py(root, "f.py", "v = 1\n")
1165 c1 = _make_commit(root, repo_id, branch="branch-a", message="C1",
1166 manifest={"f.py": f1})
1167
1168 f2 = _write_py(root, "f.py", "v = 2\n")
1169 c2 = _make_commit(root, repo_id, branch="branch-b", message="C2",
1170 manifest={"f.py": f2})
1171
1172 # C3: a merge commit combining C1 and C2 — just use C1's snapshot for simplicity.
1173 c3 = _make_commit(root, repo_id, branch="main", message="C3: merge",
1174 manifest={"f.py": f1},
1175 parent_commit_id=c1, parent2_commit_id=c2)
1176
1177 lca = find_merge_base(root, c1, c3)
1178 assert lca == c1, (
1179 f"LCA(C1, C3) should be C1 (C3 is a descendant of C1), got {lca}"
1180 )
1181
1182 lca2 = find_merge_base(root, c0, c3)
1183 assert lca2 == c0, (
1184 f"LCA(C0, C3) should be C0 (common ancestor of C0-C3 chain), got {lca2}"
1185 )
1186
1187 def test_H2_long_linear_chain_lca(self, tmp_path: pathlib.Path) -> None:
1188 """H2: 20-commit linear chain — LCA of first and last commit is the first commit."""
1189 from muse.core.merge_engine import find_merge_base
1190 root, repo_id = _init_code_repo(tmp_path)
1191
1192 f0 = _write_py(root, "f.py", "v = 0\n")
1193 first_c = _make_commit(root, repo_id, branch="main", message="C0",
1194 manifest={"f.py": f0})
1195 (root / ".muse" / "refs" / "heads" / "branch").write_text(first_c)
1196
1197 last_c = first_c
1198 for i in range(1, 21):
1199 fi = _write_py(root, "f.py", f"v = {i}\n")
1200 last_c = _make_commit(root, repo_id, branch="main", message=f"C{i}",
1201 manifest={"f.py": fi})
1202
1203 lca = find_merge_base(root, first_c, last_c)
1204 assert lca == first_c, "LCA of linear chain tip and base should be the base"
1205
1206 def test_H3_lca_of_equal_commits_is_that_commit(self, tmp_path: pathlib.Path) -> None:
1207 """H3: LCA(X, X) == X."""
1208 from muse.core.merge_engine import find_merge_base
1209 root, repo_id = _init_code_repo(tmp_path)
1210 f0 = _write_py(root, "f.py", "v = 0\n")
1211 c0 = _make_commit(root, repo_id, branch="main", message="C0",
1212 manifest={"f.py": f0})
1213 lca = find_merge_base(root, c0, c0)
1214 assert lca == c0
1215
1216 def test_H4_merge_base_with_remote_tracking_branch_topology(
1217 self, tmp_path: pathlib.Path
1218 ) -> None:
1219 """H4: simulates the exact topology of the MuseHub incident.
1220
1221 local/dev (727dad83) branched from 5e6c6476.
1222 remote/dev (e01007b4) is a merge of [5e6c6476, d40f74ba].
1223 d40f74ba includes 727dad83 in its ancestry.
1224
1225 LCA(local/dev, remote/dev) should be 5e6c6476 (NOT 727dad83),
1226 because 5e6c6476 is the common ancestor that appears first in the BFS
1227 of remote/dev's parents.
1228
1229 With this LCA, the three-way merge MUST detect that:
1230 - executor.py is a theirs-only change (theirs changed it from base, ours did not)
1231 - executor.py must appear in the merged manifest.
1232 """
1233 from muse.core.merge_engine import find_merge_base
1234 root, repo_id = _init_code_repo(tmp_path)
1235
1236 # 5e6c6476 equivalent: the proposal-list-revamp merge
1237 f_base = _write_py(root, "f.py", "v = 0\n")
1238 c_5e6c = _make_commit(root, repo_id, branch="main", message="5e6c: proposal-list-revamp",
1239 manifest={"f.py": f_base})
1240
1241 # 727dad83 equivalent: pool_pre_ping fix on top of 5e6c6476
1242 f_pp = _write_py(root, "database.py", "pool_pre_ping = True\n")
1243 c_727d = _make_commit(root, repo_id, branch="main", message="727d: pool_pre_ping",
1244 manifest={"f.py": f_base, "database.py": f_pp})
1245
1246 # d40f74ba equivalent: fix-branch HEAD (includes 727dad83 ancestor)
1247 f_ex = _write_py(root, "executor.py", "args = [] # fixed\n")
1248 (root / ".muse" / "refs" / "heads" / "fix-branch").write_text(c_727d)
1249 c_d40f = _make_commit(root, repo_id, branch="fix-branch",
1250 message="d40f: executor fix",
1251 manifest={"f.py": f_base, "database.py": f_pp,
1252 "executor.py": f_ex})
1253
1254 # e01007b4 equivalent: MuseHub merge of fix-branch into dev
1255 # parents: [5e6c6476, d40f74ba]
1256 (root / ".muse" / "refs" / "heads" / "remote-dev").write_text(c_5e6c)
1257 c_e010 = _make_commit(root, repo_id, branch="remote-dev",
1258 message="e010: Merge fix-branch into dev",
1259 manifest={"f.py": f_base, "database.py": f_pp,
1260 "executor.py": f_ex},
1261 parent_commit_id=c_5e6c,
1262 parent2_commit_id=c_d40f)
1263
1264 # The merge base of local dev (727dad83) and remote dev (e01007b4).
1265 lca = find_merge_base(root, c_727d, c_e010)
1266 assert lca == c_5e6c, (
1267 f"LCA(727dad83, e01007b4) should be 5e6c6476, got {lca}. "
1268 "With the wrong LCA, the three-way merge computes wrong change-sets "
1269 "and silently drops theirs-only files."
1270 )
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 27 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 27 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 30 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 49 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 101 days ago