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