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