gabriel / muse public

test_cmd_merge.py file-level

at sha256:d · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:4 Merge branch 'dev' into main · gabriel · Jun 17, 2026
1 """Comprehensive tests for ``muse merge``.
2
3 Covers:
4 - E2E: merge fast-forward, merge with conflicts, --format json
5 - Integration: HEAD updated after merge, conflict state written
6 - Stress: merge with many files
7 """
8
9 from __future__ import annotations
10
11 type _FileStore = dict[str, bytes]
12
13 import datetime
14 import json
15 import pathlib
16
17 import pytest
18 from tests.cli_test_helper import CliRunner
19 from muse.core.types import blob_id, fake_id
20 from muse.core.object_store import object_path
21 from muse.core.paths import heads_dir, muse_dir, ref_path
22
23 cli = None # argparse migration — CliRunner ignores this arg
24
25 runner = CliRunner()
26
27
28 # ---------------------------------------------------------------------------
29 # Shared helpers
30 # ---------------------------------------------------------------------------
31
32 def _env(root: pathlib.Path) -> Manifest:
33 return {"MUSE_REPO_ROOT": str(root)}
34
35
36 def _init_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]:
37 dot_muse = muse_dir(tmp_path)
38 dot_muse.mkdir()
39 repo_id = fake_id("repo")
40 (dot_muse / "repo.json").write_text(json.dumps({
41 "repo_id": repo_id,
42 "domain": "code",
43 "default_branch": "main",
44 "created_at": "2025-01-01T00:00:00+00:00",
45 }), encoding="utf-8")
46 (dot_muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
47 (dot_muse / "refs" / "heads").mkdir(parents=True)
48 (dot_muse / "snapshots").mkdir()
49 (dot_muse / "commits").mkdir()
50 (dot_muse / "objects").mkdir()
51 return tmp_path, repo_id
52
53
54 def _make_commit(root: pathlib.Path, repo_id: str, branch: str = "main",
55 message: str = "test",
56 manifest: Manifest | None = None) -> str:
57 from muse.core.commits import (
58 CommitRecord,
59 write_commit,
60 )
61 from muse.core.snapshots import (
62 SnapshotRecord,
63 write_snapshot,
64 )
65 from muse.core.ids import hash_snapshot, hash_commit
66
67 ref_file = ref_path(root, branch)
68 parent_id = ref_file.read_text().strip() if ref_file.exists() else None
69 m = manifest or {}
70 snap_id = hash_snapshot(m)
71 committed_at = datetime.datetime.now(datetime.timezone.utc)
72 commit_id = hash_commit( parent_ids=[parent_id] if parent_id else [],
73 snapshot_id=snap_id, message=message,
74 committed_at_iso=committed_at.isoformat(),
75 )
76 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=m))
77 write_commit(root, CommitRecord(
78 commit_id=commit_id, branch=branch,
79 snapshot_id=snap_id, message=message, committed_at=committed_at,
80 parent_commit_id=parent_id,
81 ))
82 ref_file.parent.mkdir(parents=True, exist_ok=True)
83 ref_file.write_text(commit_id, encoding="utf-8")
84 return commit_id
85
86
87 def _write_object(root: pathlib.Path, content: bytes) -> str:
88 from muse.core.object_store import write_object
89 obj_id = blob_id(content)
90 write_object(root, obj_id, content)
91 return obj_id
92
93
94 # ---------------------------------------------------------------------------
95 # Parser flag tests
96 # ---------------------------------------------------------------------------
97
98 class TestRegisterFlags:
99 def _parse(self, *args: str) -> "argparse.Namespace":
100 import argparse
101 from muse.cli.commands.merge import register
102 p = argparse.ArgumentParser()
103 sub = p.add_subparsers()
104 register(sub)
105 return p.parse_args(["merge", *args])
106
107 def test_default_json_out_is_false(self) -> None:
108 ns = self._parse("feature")
109 assert ns.json_out is False
110
111 def test_json_flag_sets_json_out(self) -> None:
112 ns = self._parse("--json", "feature")
113 assert ns.json_out is True
114
115 def test_j_shorthand_sets_json_out(self) -> None:
116 ns = self._parse("-j", "feature")
117 assert ns.json_out is True
118
119
120 # ---------------------------------------------------------------------------
121 # Tests
122 # ---------------------------------------------------------------------------
123
124 class TestMergeCLI:
125 def test_merge_branch_into_main(self, tmp_path: pathlib.Path) -> None:
126 root, repo_id = _init_repo(tmp_path)
127 base_id = _make_commit(root, repo_id, branch="main", message="base")
128 (heads_dir(root) / "feature").write_text(base_id)
129 obj = _write_object(root, b"feature content")
130 _make_commit(root, repo_id, branch="feature", message="feature work",
131 manifest={"new_track.mid": obj})
132 result = runner.invoke(cli, ["merge", "feature"], env=_env(root), catch_exceptions=False)
133 assert result.exit_code == 0
134
135 def test_merge_nonexistent_branch_fails(self, tmp_path: pathlib.Path) -> None:
136 root, repo_id = _init_repo(tmp_path)
137 _make_commit(root, repo_id)
138 result = runner.invoke(cli, ["merge", "does-not-exist"], env=_env(root))
139 assert result.exit_code != 0
140
141 def test_merge_format_json(self, tmp_path: pathlib.Path) -> None:
142 root, repo_id = _init_repo(tmp_path)
143 base_id = _make_commit(root, repo_id, branch="main", message="base")
144 (heads_dir(root) / "feature").write_text(base_id)
145 _make_commit(root, repo_id, branch="feature", message="feat")
146 result = runner.invoke(
147 cli, ["merge", "--json", "feature"], env=_env(root), catch_exceptions=False
148 )
149 assert result.exit_code == 0
150 data = json.loads(result.output)
151 assert isinstance(data, dict)
152
153 def test_merge_message_flag(self, tmp_path: pathlib.Path) -> None:
154 root, repo_id = _init_repo(tmp_path)
155 base_id = _make_commit(root, repo_id, branch="main", message="base")
156 (heads_dir(root) / "feature").write_text(base_id)
157 _make_commit(root, repo_id, branch="feature", message="feat")
158 result = runner.invoke(
159 cli, ["merge", "--message", "Merge feature", "feature"],
160 env=_env(root), catch_exceptions=False
161 )
162 assert result.exit_code == 0
163
164 def test_merge_invalid_branch_name_rejected(self, tmp_path: pathlib.Path) -> None:
165 root, repo_id = _init_repo(tmp_path)
166 _make_commit(root, repo_id)
167 result = runner.invoke(cli, ["merge", "../traversal"], env=_env(root))
168 assert result.exit_code != 0
169
170 def test_merge_output_sanitized(self, tmp_path: pathlib.Path) -> None:
171 root, repo_id = _init_repo(tmp_path)
172 base_id = _make_commit(root, repo_id, branch="main", message="base")
173 (heads_dir(root) / "feature").write_text(base_id)
174 _make_commit(root, repo_id, branch="feature", message="feat")
175 result = runner.invoke(cli, ["merge", "feature"], env=_env(root), catch_exceptions=False)
176 assert "\x1b" not in result.output
177
178
179 class TestMergeConflictWorkdir:
180 """Regression: non-conflicting additions from theirs must reach the
181 working tree even when a conflicted merge exits early.
182
183 Bug: muse merge called ``raise SystemExit`` before ``_restore_from_manifest``
184 when conflicts existed. Theirs-only file additions were computed but never
185 written to disk; ``muse checkout --theirs --all`` only resolved the
186 conflict_paths, so ``muse code add .`` missed the new files and the merge
187 commit was silently incomplete.
188 """
189
190 def _make_commit_with_files(
191 self,
192 root: pathlib.Path,
193 repo_id: str,
194 branch: str,
195 files: _FileStore,
196 parent_id: str | None = None,
197 message: str = "commit",
198 ) -> str:
199 manifest: Manifest = {}
200 for rel, content in files.items():
201 oid = _write_object(root, content)
202 manifest[rel] = oid
203 dest = root / rel
204 dest.parent.mkdir(parents=True, exist_ok=True)
205 dest.write_bytes(content)
206 return _make_commit(root, repo_id, branch=branch, message=message, manifest=manifest)
207
208 def test_theirs_only_additions_written_to_workdir_on_conflict(
209 self, tmp_path: pathlib.Path
210 ) -> None:
211 """Theirs-only new files must appear in the working tree after a
212 conflicted merge so that ``muse code add .`` captures them."""
213 root, repo_id = _init_repo(tmp_path)
214
215 # Base: one shared file that both sides will modify (guaranteeing conflict).
216 base_id = self._make_commit_with_files(
217 root, repo_id, "main",
218 {"shared.py": b"def foo(): pass\n"},
219 message="base",
220 )
221
222 # Theirs: modifies shared.py AND adds two brand-new files.
223 (heads_dir(root) / "feature").write_text(base_id)
224 self._make_commit_with_files(
225 root, repo_id, "feature",
226 {
227 "shared.py": b"def foo(): return 'theirs'\n",
228 "new_security_test.py": b"# security test\n",
229 "new_perf_test.py": b"# perf test\n",
230 },
231 message="feature: add tests + modify shared",
232 )
233
234 # Ours: also modifies shared.py (guaranteeing a conflict on that file).
235 (root / "shared.py").write_bytes(b"def foo(): return 'ours'\n")
236 _make_commit(
237 root, repo_id, "main", message="ours: modify shared",
238 manifest={"shared.py": _write_object(root, b"def foo(): return 'ours'\n")},
239 )
240
241 result = runner.invoke(cli, ["merge", "feature"], env=_env(root))
242
243 # Merge must exit with a conflict status, not a clean merge.
244 assert result.exit_code != 0, "Expected conflict exit code"
245 assert "CONFLICT" in result.stderr or "conflict" in result.stderr.lower()
246
247 # The fix: theirs-only additions MUST now exist in the working tree.
248 assert (root / "new_security_test.py").exists(), (
249 "new_security_test.py (theirs-only addition) must be written to the "
250 "working tree even though a conflict was detected on shared.py"
251 )
252 assert (root / "new_perf_test.py").exists(), (
253 "new_perf_test.py (theirs-only addition) must be written to the "
254 "working tree even though a conflict was detected on shared.py"
255 )
256 assert (root / "new_security_test.py").read_bytes() == b"# security test\n"
257 assert (root / "new_perf_test.py").read_bytes() == b"# perf test\n"
258
259 def test_conflicting_file_gets_conflict_markers(
260 self, tmp_path: pathlib.Path
261 ) -> None:
262 """Conflicting files must contain Cohen Transform markers so the user
263 can see both sides and resolve manually."""
264 root, repo_id = _init_repo(tmp_path)
265
266 base_id = self._make_commit_with_files(
267 root, repo_id, "main",
268 {"shared.py": b"def foo(): pass\n"},
269 message="base",
270 )
271
272 (heads_dir(root) / "feature").write_text(base_id)
273 self._make_commit_with_files(
274 root, repo_id, "feature",
275 {
276 "shared.py": b"def foo(): return 'theirs'\n",
277 "only_on_theirs.py": b"# new\n",
278 },
279 message="feature",
280 )
281
282 ours_content = b"def foo(): return 'ours'\n"
283 (root / "shared.py").write_bytes(ours_content)
284 _make_commit(
285 root, repo_id, "main", message="ours",
286 manifest={"shared.py": _write_object(root, ours_content)},
287 )
288
289 runner.invoke(cli, ["merge", "feature"], env=_env(root))
290
291 # Conflicting file must contain Cohen Transform markers.
292 content = (root / "shared.py").read_text(encoding="utf-8")
293 assert "<<<<<<<" in content, f"Expected conflict markers, got:\n{content}"
294 assert "return 'ours'" in content
295 assert "return 'theirs'" in content
296
297 # Theirs-only addition must be present.
298 assert (root / "only_on_theirs.py").exists()
299
300
301 class TestMergeStress:
302 def test_merge_feature_with_many_files(self, tmp_path: pathlib.Path) -> None:
303 root, repo_id = _init_repo(tmp_path)
304 base_id = _make_commit(root, repo_id, branch="main", message="base")
305 (heads_dir(root) / "feature").write_text(base_id)
306 manifest = {f"track_{i:03d}.mid": _write_object(root, f"data {i}".encode())
307 for i in range(30)}
308 _make_commit(root, repo_id, branch="feature", message="many files", manifest=manifest)
309 result = runner.invoke(cli, ["merge", "feature"], env=_env(root), catch_exceptions=False)
310 assert result.exit_code == 0
311
312
313 # ---------------------------------------------------------------------------
314 # Bug: muse merge --abort must preserve staged files on disk
315 #
316 # apply_manifest(HEAD) deletes files not in the committed HEAD manifest.
317 # Staged-but-not-committed files are not in HEAD, so they get deleted.
318 # After abort, those files should still exist on disk (they are staged work).
319 # ---------------------------------------------------------------------------
320
321 class TestMergeAbortPreservesStagedFiles:
322
323 def test_abort_leaves_staged_new_file_on_disk(self, tmp_path: pathlib.Path) -> None:
324 """muse merge --abort must not delete a staged-but-uncommitted new file."""
325 from tests.cli_test_helper import CliRunner
326 r = CliRunner()
327 env = {"MUSE_REPO_ROOT": str(tmp_path)}
328
329 # Init repo via muse init so staging is wired up.
330 r.invoke(cli, ["init"], env=env, catch_exceptions=False)
331
332 # First commit: base file.
333 (tmp_path / "base.py").write_text("base\n")
334 r.invoke(cli, ["code", "add", "base.py"], env=env, catch_exceptions=False)
335 r.invoke(cli, ["commit", "-m", "base"], env=env, catch_exceptions=False)
336
337 # Create feature branch.
338 r.invoke(cli, ["checkout", "-b", "feature"], env=env, catch_exceptions=False)
339 (tmp_path / "feature.py").write_text("feature\n")
340 r.invoke(cli, ["code", "add", "feature.py"], env=env, catch_exceptions=False)
341 r.invoke(cli, ["commit", "-m", "feature"], env=env, catch_exceptions=False)
342
343 # Back to main, stage a new file (don't commit).
344 r.invoke(cli, ["checkout", "main"], env=env, catch_exceptions=False)
345 staged_file = tmp_path / "staged_work.py"
346 staged_file.write_text("my staged work\n")
347 r.invoke(cli, ["code", "add", "staged_work.py"], env=env, catch_exceptions=False)
348 assert staged_file.exists()
349
350 # Trigger a merge that conflicts (feature changed base.py, main also will).
351 # Simplest: just start and abort immediately.
352 r.invoke(cli, ["merge", "--force", "feature"], env=env)
353
354 # Abort the merge.
355 r.invoke(cli, ["merge", "--abort"], env=env, catch_exceptions=False)
356
357 # The staged file must still be on disk.
358 assert staged_file.exists(), \
359 "muse merge --abort deleted a staged-but-uncommitted file from disk"
360 assert staged_file.read_text() == "my staged work\n"
361
362 def test_abort_leaves_staged_modification_on_disk(self, tmp_path: pathlib.Path) -> None:
363 """muse merge --abort must not revert a staged modification."""
364 from tests.cli_test_helper import CliRunner
365 r = CliRunner()
366 env = {"MUSE_REPO_ROOT": str(tmp_path)}
367
368 r.invoke(cli, ["init"], env=env, catch_exceptions=False)
369 (tmp_path / "work.py").write_text("v1\n")
370 r.invoke(cli, ["code", "add", "work.py"], env=env, catch_exceptions=False)
371 r.invoke(cli, ["commit", "-m", "base"], env=env, catch_exceptions=False)
372
373 r.invoke(cli, ["checkout", "-b", "feature"], env=env, catch_exceptions=False)
374 (tmp_path / "other.py").write_text("other\n")
375 r.invoke(cli, ["code", "add", "other.py"], env=env, catch_exceptions=False)
376 r.invoke(cli, ["commit", "-m", "feature"], env=env, catch_exceptions=False)
377
378 r.invoke(cli, ["checkout", "main"], env=env, catch_exceptions=False)
379 # Stage a modification to work.py.
380 (tmp_path / "work.py").write_text("v2\n")
381 r.invoke(cli, ["code", "add", "work.py"], env=env, catch_exceptions=False)
382
383 r.invoke(cli, ["merge", "--force", "feature"], env=env)
384 r.invoke(cli, ["merge", "--abort"], env=env, catch_exceptions=False)
385
386 # The staged version (v2) must be on disk, not the committed version (v1).
387 assert (tmp_path / "work.py").read_text() == "v2\n", \
388 "muse merge --abort reverted a staged modification"
389
390
391 # ---------------------------------------------------------------------------
392 # Bug: one-sided changes must not produce false conflicts at CLI level
393 #
394 # Scenario: our branch doesn't touch file A; theirs changes file A.
395 # merge must complete cleanly — no conflicts, file A takes theirs' version.
396 # ---------------------------------------------------------------------------
397
398 class TestOneSidedChangeNeverConflicts:
399
400 def test_theirs_only_changes_file_clean_merge(self, tmp_path: pathlib.Path) -> None:
401 root, repo_id = _init_repo(tmp_path)
402 # base: two files
403 base_id = _make_commit(root, repo_id, branch="main", message="base", manifest={
404 "describe.py": _write_object(root, b"old describe\n"),
405 "pyproject.toml": _write_object(root, b"version = 1\n"),
406 })
407 # feature branch: only changes describe.py and pyproject.toml
408 (heads_dir(root) / "feature").write_text(base_id)
409 _make_commit(root, repo_id, branch="feature", message="fix", manifest={
410 "describe.py": _write_object(root, b"fixed describe\n"),
411 "pyproject.toml": _write_object(root, b"version = 2\n"),
412 })
413 # ours (main) makes an unrelated commit without touching those files
414 _make_commit(root, repo_id, branch="main", message="our unrelated work", manifest={
415 "describe.py": _write_object(root, b"old describe\n"),
416 "pyproject.toml": _write_object(root, b"version = 1\n"),
417 "new_file.py": _write_object(root, b"new\n"),
418 })
419 result = runner.invoke(
420 cli, ["merge", "--force", "--json", "feature"],
421 env=_env(root), catch_exceptions=False
422 )
423 assert result.exit_code == 0
424 data = json.loads(result.output)
425 assert data["status"] in ("merged", "fast_forward")
426 assert data["conflicts"] == []
427
428 def test_both_sides_change_different_files_clean_merge(self, tmp_path: pathlib.Path) -> None:
429 root, repo_id = _init_repo(tmp_path)
430 base_id = _make_commit(root, repo_id, branch="main", message="base", manifest={
431 "a.py": _write_object(root, b"a\n"),
432 "b.py": _write_object(root, b"b\n"),
433 })
434 (heads_dir(root) / "feature").write_text(base_id)
435 # feature: changes b.py only
436 _make_commit(root, repo_id, branch="feature", message="change b", manifest={
437 "a.py": _write_object(root, b"a\n"),
438 "b.py": _write_object(root, b"b-theirs\n"),
439 })
440 # main: changes a.py only
441 _make_commit(root, repo_id, branch="main", message="change a", manifest={
442 "a.py": _write_object(root, b"a-ours\n"),
443 "b.py": _write_object(root, b"b\n"),
444 })
445 result = runner.invoke(
446 cli, ["merge", "--force", "--json", "feature"],
447 env=_env(root), catch_exceptions=False
448 )
449 assert result.exit_code == 0
450 data = json.loads(result.output)
451 assert data["conflicts"] == []
452
453
454 # ---------------------------------------------------------------------------
455 # Bug: muse commit completing a merge must produce a hash-verified commit
456 #
457 # After resolving conflicts and running `muse commit`, the resulting commit
458 # (with two parents) must pass write_commit's content-hash verification.
459 # Previously this raised ValueError with "incoming record failed hash
460 # verification", permanently blocking merge completion.
461 # ---------------------------------------------------------------------------
462
463 class TestMergeCommitCompletion:
464
465 def test_commit_after_conflict_resolution_passes_hash_verification(
466 self, tmp_path: pathlib.Path
467 ) -> None:
468 from muse.core.commits import read_commit
469
470 root, repo_id = _init_repo(tmp_path)
471 base_id = _make_commit(root, repo_id, branch="main", message="base", manifest={
472 "shared.py": _write_object(root, b"base\n"),
473 })
474 (heads_dir(root) / "feature").write_text(base_id)
475 # feature: changes shared.py
476 _make_commit(root, repo_id, branch="feature", message="theirs", manifest={
477 "shared.py": _write_object(root, b"theirs\n"),
478 })
479 # main: also changes shared.py (true conflict)
480 _make_commit(root, repo_id, branch="main", message="ours", manifest={
481 "shared.py": _write_object(root, b"ours\n"),
482 })
483 (root / "shared.py").write_bytes(b"ours\n")
484
485 # Trigger the merge — expect conflict
486 merge_result = runner.invoke(
487 cli, ["merge", "--force", "--json", "feature"], env=_env(root)
488 )
489 data = json.loads(merge_result.output)
490 assert data["status"] == "conflict"
491
492 # Resolve via checkout --theirs (updates merge state conflict list)
493 runner.invoke(cli, ["checkout", "--theirs", "shared.py"], env=_env(root), catch_exceptions=False)
494 runner.invoke(cli, ["code", "add", "shared.py"], env=_env(root), catch_exceptions=False)
495
496 # Complete the merge
497 commit_result = runner.invoke(
498 cli, ["commit", "--json", "-m", "merge: resolve conflict"],
499 env=_env(root), catch_exceptions=False
500 )
501 assert commit_result.exit_code == 0, f"commit failed: {commit_result.output}"
502 commit_data = json.loads(commit_result.output)
503 assert "commit_id" in commit_data
504
505 # The commit must have two parents and pass hash verification
506 cid = commit_data["commit_id"]
507 rec = read_commit(root, cid)
508 assert rec is not None
509 assert rec.parent2_commit_id is not None, "merge commit must have two parents"
510 assert rec.commit_id == cid