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