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