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