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