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