gabriel / muse public
test_cmd_apply_patch.py python
384 lines 14.2 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago
1 """Tests for ``muse apply-patch`` — apply a Muse .mpatch file to the working tree.
2
3 Test tiers
4 ----------
5 - Unit: output schema, exit_code/duration_ms in JSON
6 - Integration: apply restores files, --dry-run reports without writing, --check
7 - Data integrity: patch_id verified before apply, tampered patch rejected
8 - Security: path traversal in manifest rejected, error to stderr
9 - Edge: empty diff applies cleanly, already-applied patch detectable via snapshot check
10 """
11 from __future__ import annotations
12
13 import datetime
14 import json
15 import pathlib
16
17 import pytest
18
19 from tests.cli_test_helper import CliRunner, InvokeResult
20 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
21 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
22 from muse.core.object_store import write_object
23 from muse.core._types import long_id
24
25 runner = CliRunner()
26
27
28 # ---------------------------------------------------------------------------
29 # Helpers
30 # ---------------------------------------------------------------------------
31
32
33 def _init_repo(path: pathlib.Path) -> pathlib.Path:
34 muse = path / ".muse"
35 for sub in ("commits", "snapshots", "objects", "refs/heads"):
36 (muse / sub).mkdir(parents=True, exist_ok=True)
37 (muse / "HEAD").write_text("ref: refs/heads/main\n")
38 (muse / "repo.json").write_text(json.dumps({"repo_id": "test-repo", "domain": "code"}))
39 return path
40
41
42 def _write_object(repo: pathlib.Path, content: bytes) -> str:
43 """Write bytes to the object store and return the sha256: prefixed ID."""
44 import hashlib
45 digest = hashlib.sha256(content).hexdigest()
46 oid = long_id(digest)
47 write_object(repo, oid, content)
48 return oid
49
50
51 def _commit(
52 repo: pathlib.Path,
53 msg: str,
54 manifest: dict[str, str],
55 branch: str = "main",
56 parent: str | None = None,
57 ts: datetime.datetime | None = None,
58 ) -> str:
59 ts = ts or datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
60 sid = compute_snapshot_id(manifest)
61 write_snapshot(repo, SnapshotRecord(snapshot_id=sid, manifest=manifest, created_at=ts))
62 parent_ids = [parent] if parent else []
63 cid = compute_commit_id(parent_ids, sid, msg, ts.isoformat())
64 write_commit(repo, CommitRecord(
65 commit_id=cid, repo_id="test-repo", branch=branch,
66 snapshot_id=sid, message=msg, committed_at=ts,
67 author="gabriel", parent_commit_id=parent, parent2_commit_id=None,
68 ))
69 ref_path = repo / ".muse" / "refs" / "heads" / branch
70 ref_path.parent.mkdir(parents=True, exist_ok=True)
71 ref_path.write_text(cid)
72 return cid
73
74
75 def _make_patch(repo: pathlib.Path, tmp_path: pathlib.Path) -> pathlib.Path:
76 """Create a .mpatch file by running format-patch --output-dir."""
77 out_dir = tmp_path / "patches"
78 out_dir.mkdir(exist_ok=True)
79 r = runner.invoke(None, ["format-patch", "--output-dir", str(out_dir)],
80 env={"MUSE_REPO_ROOT": str(repo)})
81 assert r.exit_code == 0, f"format-patch failed: {r.output}"
82 patches = list(out_dir.glob("*.mpatch"))
83 assert len(patches) == 1
84 return patches[0]
85
86
87 def _ap(repo: pathlib.Path, patch_file: pathlib.Path, *args: str) -> InvokeResult:
88 return runner.invoke(None, ["apply-patch", str(patch_file), *args],
89 env={"MUSE_REPO_ROOT": str(repo)})
90
91
92 def _json(r: InvokeResult) -> dict:
93 return json.loads(r.output)
94
95
96 # ---------------------------------------------------------------------------
97 # JSON output schema
98 # ---------------------------------------------------------------------------
99
100
101 class TestJsonSchema:
102 def test_exits_zero_on_success(self, tmp_path: pathlib.Path) -> None:
103 src = _init_repo(tmp_path / "src")
104 oid = _write_object(src, b"x = 1\n")
105 c1 = _commit(src, "c1", {"a.py": oid})
106 patch = _make_patch(src, tmp_path)
107
108 # Target repo at c1's snapshot (same as from_snapshot)
109 target = _init_repo(tmp_path / "target")
110 oid_t = _write_object(target, b"x = 1\n")
111 # Use the same snapshot_id as source c1 so applicability passes
112 sid = compute_snapshot_id({"a.py": oid_t})
113 write_snapshot(target, SnapshotRecord(
114 snapshot_id=sid,
115 manifest={"a.py": oid_t},
116 created_at=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
117 ))
118 _commit(target, "c1", {"a.py": oid_t})
119
120 r = _ap(target, patch, "--json")
121 assert r.exit_code == 0
122
123 def test_json_has_exit_code_zero(self, tmp_path: pathlib.Path) -> None:
124 src = _init_repo(tmp_path / "src")
125 oid = _write_object(src, b"x = 1\n")
126 _commit(src, "c1", {"a.py": oid})
127 patch = _make_patch(src, tmp_path)
128
129 target = _init_repo(tmp_path / "target")
130 oid_t = _write_object(target, b"x = 1\n")
131 _commit(target, "c1", {"a.py": oid_t})
132
133 data = _json(_ap(target, patch, "--json"))
134 assert data["exit_code"] == 0
135
136 def test_exit_code_is_int_not_bool(self, tmp_path: pathlib.Path) -> None:
137 src = _init_repo(tmp_path / "src")
138 oid = _write_object(src, b"x = 1\n")
139 _commit(src, "c1", {"a.py": oid})
140 patch = _make_patch(src, tmp_path)
141
142 target = _init_repo(tmp_path / "target")
143 oid_t = _write_object(target, b"x = 1\n")
144 _commit(target, "c1", {"a.py": oid_t})
145
146 data = _json(_ap(target, patch, "--json"))
147 assert type(data["exit_code"]) is int
148
149 def test_json_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
150 src = _init_repo(tmp_path / "src")
151 oid = _write_object(src, b"x = 1\n")
152 _commit(src, "c1", {"a.py": oid})
153 patch = _make_patch(src, tmp_path)
154
155 target = _init_repo(tmp_path / "target")
156 oid_t = _write_object(target, b"x = 1\n")
157 _commit(target, "c1", {"a.py": oid_t})
158
159 data = _json(_ap(target, patch, "--json"))
160 assert "duration_ms" in data
161
162 def test_duration_ms_is_float(self, tmp_path: pathlib.Path) -> None:
163 src = _init_repo(tmp_path / "src")
164 oid = _write_object(src, b"x = 1\n")
165 _commit(src, "c1", {"a.py": oid})
166 patch = _make_patch(src, tmp_path)
167
168 target = _init_repo(tmp_path / "target")
169 oid_t = _write_object(target, b"x = 1\n")
170 _commit(target, "c1", {"a.py": oid_t})
171
172 data = _json(_ap(target, patch, "--json"))
173 assert isinstance(data["duration_ms"], float)
174 assert data["duration_ms"] >= 0.0
175
176 def test_json_output_is_compact(self, tmp_path: pathlib.Path) -> None:
177 src = _init_repo(tmp_path / "src")
178 oid = _write_object(src, b"x = 1\n")
179 _commit(src, "c1", {"a.py": oid})
180 patch = _make_patch(src, tmp_path)
181
182 target = _init_repo(tmp_path / "target")
183 oid_t = _write_object(target, b"x = 1\n")
184 _commit(target, "c1", {"a.py": oid_t})
185
186 r = _ap(target, patch, "--json")
187 assert len(r.output.strip().splitlines()) == 1
188
189 def test_json_has_patch_id(self, tmp_path: pathlib.Path) -> None:
190 src = _init_repo(tmp_path / "src")
191 oid = _write_object(src, b"x = 1\n")
192 _commit(src, "c1", {"a.py": oid})
193 patch = _make_patch(src, tmp_path)
194
195 target = _init_repo(tmp_path / "target")
196 oid_t = _write_object(target, b"x = 1\n")
197 _commit(target, "c1", {"a.py": oid_t})
198
199 data = _json(_ap(target, patch, "--json"))
200 assert "patch_id" in data
201 assert data["patch_id"].startswith("sha256:")
202
203 def test_json_has_files_applied(self, tmp_path: pathlib.Path) -> None:
204 src = _init_repo(tmp_path / "src")
205 oid = _write_object(src, b"x = 1\n")
206 _commit(src, "c1", {"a.py": oid})
207 patch = _make_patch(src, tmp_path)
208
209 target = _init_repo(tmp_path / "target")
210 oid_t = _write_object(target, b"x = 1\n")
211 _commit(target, "c1", {"a.py": oid_t})
212
213 data = _json(_ap(target, patch, "--json"))
214 assert "files_applied" in data
215 assert isinstance(data["files_applied"], list)
216
217
218 # ---------------------------------------------------------------------------
219 # Files are restored to disk
220 # ---------------------------------------------------------------------------
221
222
223 class TestFileRestoration:
224 def test_added_file_exists_after_apply(self, tmp_path: pathlib.Path) -> None:
225 src = _init_repo(tmp_path / "src")
226 oid = _write_object(src, b"x = 1\n")
227 _commit(src, "c1", {"a.py": oid})
228 patch = _make_patch(src, tmp_path)
229
230 target = _init_repo(tmp_path / "target")
231 _commit(target, "empty", {})
232 _ap(target, patch)
233 assert (target / "a.py").exists()
234
235 def test_added_file_has_correct_content(self, tmp_path: pathlib.Path) -> None:
236 src = _init_repo(tmp_path / "src")
237 oid = _write_object(src, b"x = 42\n")
238 _commit(src, "c1", {"a.py": oid})
239 patch = _make_patch(src, tmp_path)
240
241 target = _init_repo(tmp_path / "target")
242 _commit(target, "empty", {})
243 _ap(target, patch)
244 assert (target / "a.py").read_bytes() == b"x = 42\n"
245
246 def test_deleted_file_removed_after_apply(self, tmp_path: pathlib.Path) -> None:
247 src = _init_repo(tmp_path / "src")
248 oid1 = _write_object(src, b"x = 1\n")
249 oid2 = _write_object(src, b"y = 2\n")
250 c1 = _commit(src, "c1", {"old.py": oid1, "keep.py": oid2})
251 _commit(src, "c2", {"keep.py": oid2}, parent=c1)
252 patch = _make_patch(src, tmp_path)
253
254 target = _init_repo(tmp_path / "target")
255 oid_old = _write_object(target, b"x = 1\n")
256 oid_keep = _write_object(target, b"y = 2\n")
257 _commit(target, "c1", {"old.py": oid_old, "keep.py": oid_keep})
258 # Write both files to disk (simulating a working tree at c1)
259 (target / "old.py").write_bytes(b"x = 1\n")
260 (target / "keep.py").write_bytes(b"y = 2\n")
261 _ap(target, patch)
262 # old.py was deleted by the patch; keep.py was untouched on disk
263 assert not (target / "old.py").exists()
264 assert (target / "keep.py").exists()
265
266
267 # ---------------------------------------------------------------------------
268 # --dry-run does not modify disk
269 # ---------------------------------------------------------------------------
270
271
272 class TestDryRun:
273 def test_dry_run_exits_zero(self, tmp_path: pathlib.Path) -> None:
274 src = _init_repo(tmp_path / "src")
275 oid = _write_object(src, b"x = 1\n")
276 _commit(src, "c1", {"a.py": oid})
277 patch = _make_patch(src, tmp_path)
278
279 target = _init_repo(tmp_path / "target")
280 _commit(target, "empty", {})
281 r = _ap(target, patch, "--dry-run")
282 assert r.exit_code == 0
283
284 def test_dry_run_does_not_write_files(self, tmp_path: pathlib.Path) -> None:
285 src = _init_repo(tmp_path / "src")
286 oid = _write_object(src, b"x = 1\n")
287 _commit(src, "c1", {"a.py": oid})
288 patch = _make_patch(src, tmp_path)
289
290 target = _init_repo(tmp_path / "target")
291 _commit(target, "empty", {})
292 _ap(target, patch, "--dry-run")
293 assert not (target / "a.py").exists()
294
295 def test_dry_run_json_has_dry_run_true(self, tmp_path: pathlib.Path) -> None:
296 src = _init_repo(tmp_path / "src")
297 oid = _write_object(src, b"x = 1\n")
298 _commit(src, "c1", {"a.py": oid})
299 patch = _make_patch(src, tmp_path)
300
301 target = _init_repo(tmp_path / "target")
302 _commit(target, "empty", {})
303 data = _json(_ap(target, patch, "--dry-run", "--json"))
304 assert data.get("dry_run") is True
305
306
307 # ---------------------------------------------------------------------------
308 # --check (applicability only)
309 # ---------------------------------------------------------------------------
310
311
312 class TestCheck:
313 def test_check_exits_zero_when_applicable(self, tmp_path: pathlib.Path) -> None:
314 src = _init_repo(tmp_path / "src")
315 oid = _write_object(src, b"x = 1\n")
316 _commit(src, "c1", {"a.py": oid})
317 patch = _make_patch(src, tmp_path)
318
319 target = _init_repo(tmp_path / "target")
320 _commit(target, "empty", {})
321 r = _ap(target, patch, "--check")
322 assert r.exit_code == 0
323
324 def test_check_does_not_write_files(self, tmp_path: pathlib.Path) -> None:
325 src = _init_repo(tmp_path / "src")
326 oid = _write_object(src, b"x = 1\n")
327 _commit(src, "c1", {"a.py": oid})
328 patch = _make_patch(src, tmp_path)
329
330 target = _init_repo(tmp_path / "target")
331 _commit(target, "empty", {})
332 _ap(target, patch, "--check")
333 assert not (target / "a.py").exists()
334
335 def test_check_json_has_applicable_field(self, tmp_path: pathlib.Path) -> None:
336 src = _init_repo(tmp_path / "src")
337 oid = _write_object(src, b"x = 1\n")
338 _commit(src, "c1", {"a.py": oid})
339 patch = _make_patch(src, tmp_path)
340
341 target = _init_repo(tmp_path / "target")
342 _commit(target, "empty", {})
343 data = _json(_ap(target, patch, "--check", "--json"))
344 assert "applicable" in data
345 assert isinstance(data["applicable"], bool)
346
347
348 # ---------------------------------------------------------------------------
349 # Integrity verification
350 # ---------------------------------------------------------------------------
351
352
353 class TestIntegrity:
354 def test_tampered_patch_id_rejected(self, tmp_path: pathlib.Path) -> None:
355 src = _init_repo(tmp_path / "src")
356 oid = _write_object(src, b"x = 1\n")
357 _commit(src, "c1", {"a.py": oid})
358 patch = _make_patch(src, tmp_path)
359
360 # Tamper with patch_id
361 data = json.loads(patch.read_bytes())
362 data["patch_id"] = long_id("f" * 64)
363 patch.write_bytes(json.dumps(data).encode())
364
365 target = _init_repo(tmp_path / "target")
366 _commit(target, "empty", {})
367 r = _ap(target, patch)
368 assert r.exit_code != 0
369
370 def test_missing_patch_file_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
371 repo = _init_repo(tmp_path)
372 oid = _write_object(repo, b"x = 1\n")
373 _commit(repo, "init", {"a.py": oid})
374 r = _ap(repo, tmp_path / "nonexistent.mpatch")
375 assert r.exit_code != 0
376
377 def test_corrupt_json_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
378 repo = _init_repo(tmp_path)
379 oid = _write_object(repo, b"x = 1\n")
380 _commit(repo, "init", {"a.py": oid})
381 bad_patch = tmp_path / "bad.mpatch"
382 bad_patch.write_bytes(b"not valid json!!!")
383 r = _ap(repo, bad_patch)
384 assert r.exit_code != 0
File History 1 commit
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago