gabriel / muse public
test_cmd_merge.py python
275 lines 11.2 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 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
21 cli = None # argparse migration — CliRunner ignores this arg
22
23 runner = CliRunner()
24
25
26 # ---------------------------------------------------------------------------
27 # Shared helpers
28 # ---------------------------------------------------------------------------
29
30 def _env(root: pathlib.Path) -> Manifest:
31 return {"MUSE_REPO_ROOT": str(root)}
32
33
34 def _init_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]:
35 muse_dir = tmp_path / ".muse"
36 muse_dir.mkdir()
37 repo_id = str(uuid.uuid4())
38 (muse_dir / "repo.json").write_text(json.dumps({
39 "repo_id": repo_id,
40 "domain": "code",
41 "default_branch": "main",
42 "created_at": "2025-01-01T00:00:00+00:00",
43 }), encoding="utf-8")
44 (muse_dir / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
45 (muse_dir / "refs" / "heads").mkdir(parents=True)
46 (muse_dir / "snapshots").mkdir()
47 (muse_dir / "commits").mkdir()
48 (muse_dir / "objects").mkdir()
49 return tmp_path, repo_id
50
51
52 def _make_commit(root: pathlib.Path, repo_id: str, branch: str = "main",
53 message: str = "test",
54 manifest: Manifest | None = None) -> str:
55 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
56 from muse.core.snapshot import compute_snapshot_id, compute_commit_id
57
58 ref_file = root / ".muse" / "refs" / "heads" / branch
59 parent_id = ref_file.read_text().strip() if ref_file.exists() else None
60 m = manifest or {}
61 snap_id = compute_snapshot_id(m)
62 committed_at = datetime.datetime.now(datetime.timezone.utc)
63 commit_id = compute_commit_id(
64 parent_ids=[parent_id] if parent_id else [],
65 snapshot_id=snap_id, message=message,
66 committed_at_iso=committed_at.isoformat(),
67 )
68 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=m))
69 write_commit(root, CommitRecord(
70 commit_id=commit_id, repo_id=repo_id, branch=branch,
71 snapshot_id=snap_id, message=message, committed_at=committed_at,
72 parent_commit_id=parent_id,
73 ))
74 ref_file.parent.mkdir(parents=True, exist_ok=True)
75 ref_file.write_text(commit_id, encoding="utf-8")
76 return commit_id
77
78
79 def _write_object(root: pathlib.Path, content: bytes) -> str:
80 import hashlib
81 obj_id = hashlib.sha256(content).hexdigest()
82 obj_path = root / ".muse" / "objects" / obj_id[:2] / obj_id[2:]
83 obj_path.parent.mkdir(parents=True, exist_ok=True)
84 obj_path.write_bytes(content)
85 return obj_id
86
87
88 # ---------------------------------------------------------------------------
89 # Tests
90 # ---------------------------------------------------------------------------
91
92 class TestMergeCLI:
93 def test_merge_branch_into_main(self, tmp_path: pathlib.Path) -> None:
94 root, repo_id = _init_repo(tmp_path)
95 base_id = _make_commit(root, repo_id, branch="main", message="base")
96 (root / ".muse" / "refs" / "heads" / "feature").write_text(base_id)
97 obj = _write_object(root, b"feature content")
98 _make_commit(root, repo_id, branch="feature", message="feature work",
99 manifest={"new_track.mid": obj})
100 result = runner.invoke(cli, ["merge", "feature"], env=_env(root), catch_exceptions=False)
101 assert result.exit_code == 0
102
103 def test_merge_nonexistent_branch_fails(self, tmp_path: pathlib.Path) -> None:
104 root, repo_id = _init_repo(tmp_path)
105 _make_commit(root, repo_id)
106 result = runner.invoke(cli, ["merge", "does-not-exist"], env=_env(root))
107 assert result.exit_code != 0
108
109 def test_merge_format_json(self, tmp_path: pathlib.Path) -> None:
110 root, repo_id = _init_repo(tmp_path)
111 base_id = _make_commit(root, repo_id, branch="main", message="base")
112 (root / ".muse" / "refs" / "heads" / "feature").write_text(base_id)
113 _make_commit(root, repo_id, branch="feature", message="feat")
114 result = runner.invoke(
115 cli, ["merge", "--format", "json", "feature"], env=_env(root), catch_exceptions=False
116 )
117 assert result.exit_code == 0
118 data = json.loads(result.output)
119 assert isinstance(data, dict)
120
121 def test_merge_message_flag(self, tmp_path: pathlib.Path) -> None:
122 root, repo_id = _init_repo(tmp_path)
123 base_id = _make_commit(root, repo_id, branch="main", message="base")
124 (root / ".muse" / "refs" / "heads" / "feature").write_text(base_id)
125 _make_commit(root, repo_id, branch="feature", message="feat")
126 result = runner.invoke(
127 cli, ["merge", "--message", "Merge feature", "feature"],
128 env=_env(root), catch_exceptions=False
129 )
130 assert result.exit_code == 0
131
132 def test_merge_invalid_branch_name_rejected(self, tmp_path: pathlib.Path) -> None:
133 root, repo_id = _init_repo(tmp_path)
134 _make_commit(root, repo_id)
135 result = runner.invoke(cli, ["merge", "../evil"], env=_env(root))
136 assert result.exit_code != 0
137
138 def test_merge_output_sanitized(self, tmp_path: pathlib.Path) -> None:
139 root, repo_id = _init_repo(tmp_path)
140 base_id = _make_commit(root, repo_id, branch="main", message="base")
141 (root / ".muse" / "refs" / "heads" / "feature").write_text(base_id)
142 _make_commit(root, repo_id, branch="feature", message="feat")
143 result = runner.invoke(cli, ["merge", "feature"], env=_env(root), catch_exceptions=False)
144 assert "\x1b" not in result.output
145
146
147 class TestMergeConflictWorkdir:
148 """Regression: non-conflicting additions from theirs must reach the
149 working tree even when a conflicted merge exits early.
150
151 Bug: muse merge called ``raise SystemExit`` before ``_restore_from_manifest``
152 when conflicts existed. Theirs-only file additions were computed but never
153 written to disk; ``muse checkout --theirs --all`` only resolved the
154 conflict_paths, so ``muse code add .`` missed the new files and the merge
155 commit was silently incomplete.
156 """
157
158 def _make_commit_with_files(
159 self,
160 root: pathlib.Path,
161 repo_id: str,
162 branch: str,
163 files: _FileStore,
164 parent_id: str | None = None,
165 message: str = "commit",
166 ) -> str:
167 manifest: Manifest = {}
168 for rel, content in files.items():
169 oid = _write_object(root, content)
170 manifest[rel] = oid
171 dest = root / rel
172 dest.parent.mkdir(parents=True, exist_ok=True)
173 dest.write_bytes(content)
174 return _make_commit(root, repo_id, branch=branch, message=message, manifest=manifest)
175
176 def test_theirs_only_additions_written_to_workdir_on_conflict(
177 self, tmp_path: pathlib.Path
178 ) -> None:
179 """Theirs-only new files must appear in the working tree after a
180 conflicted merge so that ``muse code add .`` captures them."""
181 root, repo_id = _init_repo(tmp_path)
182
183 # Base: one shared file that both sides will modify (guaranteeing conflict).
184 base_id = self._make_commit_with_files(
185 root, repo_id, "main",
186 {"shared.py": b"def foo(): pass\n"},
187 message="base",
188 )
189
190 # Theirs: modifies shared.py AND adds two brand-new files.
191 (root / ".muse" / "refs" / "heads" / "feature").write_text(base_id)
192 self._make_commit_with_files(
193 root, repo_id, "feature",
194 {
195 "shared.py": b"def foo(): return 'theirs'\n",
196 "new_security_test.py": b"# security test\n",
197 "new_perf_test.py": b"# perf test\n",
198 },
199 message="feature: add tests + modify shared",
200 )
201
202 # Ours: also modifies shared.py (guaranteeing a conflict on that file).
203 (root / "shared.py").write_bytes(b"def foo(): return 'ours'\n")
204 _make_commit(
205 root, repo_id, "main", message="ours: modify shared",
206 manifest={"shared.py": _write_object(root, b"def foo(): return 'ours'\n")},
207 )
208
209 result = runner.invoke(cli, ["merge", "feature"], env=_env(root))
210
211 # Merge must exit with a conflict status, not a clean merge.
212 assert result.exit_code != 0, "Expected conflict exit code"
213 assert "CONFLICT" in result.output or "conflict" in result.output.lower()
214
215 # The fix: theirs-only additions MUST now exist in the working tree.
216 assert (root / "new_security_test.py").exists(), (
217 "new_security_test.py (theirs-only addition) must be written to the "
218 "working tree even though a conflict was detected on shared.py"
219 )
220 assert (root / "new_perf_test.py").exists(), (
221 "new_perf_test.py (theirs-only addition) must be written to the "
222 "working tree even though a conflict was detected on shared.py"
223 )
224 assert (root / "new_security_test.py").read_bytes() == b"# security test\n"
225 assert (root / "new_perf_test.py").read_bytes() == b"# perf test\n"
226
227 def test_conflicting_file_left_at_ours_version_on_conflict(
228 self, tmp_path: pathlib.Path
229 ) -> None:
230 """Conflicting files must remain at their ours content in the working
231 tree after a partial restore — the agent resolves via --ours/--theirs."""
232 root, repo_id = _init_repo(tmp_path)
233
234 base_id = self._make_commit_with_files(
235 root, repo_id, "main",
236 {"shared.py": b"def foo(): pass\n"},
237 message="base",
238 )
239
240 (root / ".muse" / "refs" / "heads" / "feature").write_text(base_id)
241 self._make_commit_with_files(
242 root, repo_id, "feature",
243 {
244 "shared.py": b"def foo(): return 'theirs'\n",
245 "only_on_theirs.py": b"# new\n",
246 },
247 message="feature",
248 )
249
250 ours_content = b"def foo(): return 'ours'\n"
251 (root / "shared.py").write_bytes(ours_content)
252 _make_commit(
253 root, repo_id, "main", message="ours",
254 manifest={"shared.py": _write_object(root, ours_content)},
255 )
256
257 runner.invoke(cli, ["merge", "feature"], env=_env(root))
258
259 # Conflicting file must stay at ours content for agent inspection.
260 assert (root / "shared.py").read_bytes() == ours_content
261
262 # Theirs-only addition must be present.
263 assert (root / "only_on_theirs.py").exists()
264
265
266 class TestMergeStress:
267 def test_merge_feature_with_many_files(self, tmp_path: pathlib.Path) -> None:
268 root, repo_id = _init_repo(tmp_path)
269 base_id = _make_commit(root, repo_id, branch="main", message="base")
270 (root / ".muse" / "refs" / "heads" / "feature").write_text(base_id)
271 manifest = {f"track_{i:03d}.mid": _write_object(root, f"data {i}".encode())
272 for i in range(30)}
273 _make_commit(root, repo_id, branch="feature", message="many files", manifest=manifest)
274 result = runner.invoke(cli, ["merge", "feature"], env=_env(root), catch_exceptions=False)
275 assert result.exit_code == 0
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago