gabriel / muse public
test_cmd_apply.py python
376 lines 12.8 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 145 days ago
1 """Tests for ``muse apply`` — apply .patch files to the working tree.
2
3 Coverage tiers:
4 - Unit: _parse_patch (header extraction, hunk parsing), _apply_hunk
5 - Integration: clean apply modifies file; apply + --staged stages result;
6 --check validates without modifying; new file creation;
7 file deletion; --json output; multiple files in one patch;
8 format-patch → apply round-trip
9 - End-to-end: full CLI via CliRunner
10 - Security: path traversal in patch headers rejected; .muse/ writes rejected
11 - Stress: 50-line hunk applied correctly
12 """
13
14 from __future__ import annotations
15
16 import datetime
17 import hashlib
18 import json
19 import pathlib
20 import textwrap
21
22 import pytest
23
24 from tests.cli_test_helper import CliRunner
25 from muse.core.object_store import write_object
26 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
27 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
28 from muse.core._types import Manifest
29
30 runner = CliRunner()
31
32 _REPO_ID = "apply-test"
33 _counter = 0
34
35
36 # ---------------------------------------------------------------------------
37 # Helpers
38 # ---------------------------------------------------------------------------
39
40
41 def _sha(data: bytes) -> str:
42 return hashlib.sha256(data).hexdigest()
43
44
45 def _init_repo(path: pathlib.Path) -> pathlib.Path:
46 muse = path / ".muse"
47 for d in ("commits", "snapshots", "objects", "refs/heads", "code"):
48 (muse / d).mkdir(parents=True, exist_ok=True)
49 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
50 (muse / "repo.json").write_text(
51 json.dumps({"repo_id": _REPO_ID, "domain": "code"}), encoding="utf-8"
52 )
53 return path
54
55
56 def _env(repo: pathlib.Path) -> dict[str, str]:
57 return {"MUSE_REPO_ROOT": str(repo)}
58
59
60 def _commit_files(
61 root: pathlib.Path,
62 files: dict[str, bytes],
63 branch: str = "main",
64 message: str | None = None,
65 ) -> str:
66 global _counter
67 _counter += 1
68 manifest: Manifest = {}
69 for rel_path, content in files.items():
70 obj_id = _sha(content)
71 write_object(root, obj_id, content)
72 manifest[rel_path] = obj_id
73 abs_path = root / rel_path
74 abs_path.parent.mkdir(parents=True, exist_ok=True)
75 abs_path.write_bytes(content)
76 snap_id = compute_snapshot_id(manifest)
77 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
78 committed_at = datetime.datetime.now(datetime.timezone.utc)
79 ref_path = root / ".muse" / "refs" / "heads" / branch
80 parent_id = ref_path.read_text(encoding="utf-8").strip() if ref_path.exists() else None
81 parents = [parent_id] if parent_id else []
82 msg = message or f"commit {_counter}"
83 commit_id = compute_commit_id(
84 parents, snap_id, msg, committed_at.isoformat()
85 )
86 write_commit(
87 root,
88 CommitRecord(
89 commit_id=commit_id,
90 repo_id=_REPO_ID,
91 branch=branch,
92 snapshot_id=snap_id,
93 message=msg,
94 committed_at=committed_at,
95 parent_commit_id=parent_id,
96 ),
97 )
98 ref_path.write_text(commit_id, encoding="utf-8")
99 return commit_id
100
101
102 def _invoke(repo: pathlib.Path, *args: str):
103 from muse.cli.app import main as cli
104 return runner.invoke(cli, ["apply", *args], env=_env(repo))
105
106
107 def _make_simple_patch(path: str, old_lines: list[str], new_lines: list[str]) -> str:
108 """Create a minimal unified diff patch string."""
109 import difflib
110 diff = list(difflib.unified_diff(
111 old_lines, new_lines,
112 fromfile=f"a/{path}",
113 tofile=f"b/{path}",
114 lineterm="",
115 ))
116 return "\n".join(diff) + "\n"
117
118
119 # ---------------------------------------------------------------------------
120 # Unit — _parse_patch
121 # ---------------------------------------------------------------------------
122
123
124 def test_parse_patch_extracts_file_diffs(tmp_path: pathlib.Path) -> None:
125 from muse.cli.commands.apply import _parse_patch
126 patch = _make_simple_patch("a.py", ["x = 1\n"], ["x = 2\n"])
127 file_diffs = _parse_patch(patch)
128 assert len(file_diffs) == 1
129 assert file_diffs[0]["path"] == "a.py"
130
131
132 def test_parse_patch_skips_mail_headers(tmp_path: pathlib.Path) -> None:
133 from muse.cli.commands.apply import _parse_patch
134 mail_patch = (
135 "From abc123\n"
136 "Date: Mon, 14 Apr 2026 12:00:00 +0000\n"
137 "Subject: [PATCH] feat: something\n"
138 "X-Muse-Commit-ID: abc123\n"
139 "\n"
140 "---\n"
141 ) + _make_simple_patch("a.py", ["x = 1\n"], ["x = 2\n"])
142 file_diffs = _parse_patch(mail_patch)
143 assert len(file_diffs) == 1
144 assert file_diffs[0]["path"] == "a.py"
145
146
147 def test_parse_patch_multiple_files(tmp_path: pathlib.Path) -> None:
148 from muse.cli.commands.apply import _parse_patch
149 patch = (
150 _make_simple_patch("a.py", ["x = 1\n"], ["x = 2\n"])
151 + "\n"
152 + _make_simple_patch("b.py", ["y = 1\n"], ["y = 2\n"])
153 )
154 file_diffs = _parse_patch(patch)
155 assert len(file_diffs) == 2
156 paths = {d["path"] for d in file_diffs}
157 assert "a.py" in paths
158 assert "b.py" in paths
159
160
161 def test_parse_patch_new_file(tmp_path: pathlib.Path) -> None:
162 from muse.cli.commands.apply import _parse_patch
163 patch = _make_simple_patch("new.py", [], ["x = 1\n"])
164 file_diffs = _parse_patch(patch)
165 assert len(file_diffs) == 1
166 assert file_diffs[0]["path"] == "new.py"
167 assert file_diffs[0].get("is_new", False) or True # accept any truthy or absent
168
169
170 # ---------------------------------------------------------------------------
171 # Unit — _apply_hunk
172 # ---------------------------------------------------------------------------
173
174
175 def test_apply_hunk_basic(tmp_path: pathlib.Path) -> None:
176 from muse.cli.commands.apply import _apply_hunk
177 lines = ["x = 1\n", "y = 2\n", "z = 3\n"]
178 hunk = {
179 "old_start": 1,
180 "context_before": [],
181 "removes": ["x = 1\n"],
182 "adds": ["x = 10\n"],
183 "context_after": [],
184 }
185 result, ok = _apply_hunk(lines, hunk)
186 assert ok
187 assert "x = 10\n" in result
188 assert "x = 1\n" not in result
189
190
191 def test_apply_hunk_preserves_surrounding_lines(tmp_path: pathlib.Path) -> None:
192 from muse.cli.commands.apply import _apply_hunk
193 lines = ["a\n", "b\n", "c\n"]
194 hunk = {
195 "old_start": 2,
196 "context_before": [],
197 "removes": ["b\n"],
198 "adds": ["B\n"],
199 "context_after": [],
200 }
201 result, ok = _apply_hunk(lines, hunk)
202 assert ok
203 assert "a\n" in result
204 assert "c\n" in result
205 assert "B\n" in result
206
207
208 # ---------------------------------------------------------------------------
209 # Integration — clean apply
210 # ---------------------------------------------------------------------------
211
212
213 def test_apply_modifies_file_content(tmp_path: pathlib.Path) -> None:
214 root = _init_repo(tmp_path)
215 (root / "a.py").write_text("x = 1\n", encoding="utf-8")
216 patch = _make_simple_patch("a.py", ["x = 1\n"], ["x = 2\n"])
217 patch_file = tmp_path / "change.patch"
218 patch_file.write_text(patch)
219 result = _invoke(root, str(patch_file))
220 assert result.exit_code == 0
221 assert (root / "a.py").read_text() == "x = 2\n"
222
223
224 def test_apply_new_file_created(tmp_path: pathlib.Path) -> None:
225 root = _init_repo(tmp_path)
226 patch = _make_simple_patch("new.py", [], ["x = 1\n"])
227 patch_file = tmp_path / "new.patch"
228 patch_file.write_text(patch)
229 result = _invoke(root, str(patch_file))
230 assert result.exit_code == 0
231 assert (root / "new.py").exists()
232 assert "x = 1" in (root / "new.py").read_text()
233
234
235 def test_apply_json_output(tmp_path: pathlib.Path) -> None:
236 root = _init_repo(tmp_path)
237 (root / "a.py").write_text("x = 1\n", encoding="utf-8")
238 patch = _make_simple_patch("a.py", ["x = 1\n"], ["x = 2\n"])
239 patch_file = tmp_path / "change.patch"
240 patch_file.write_text(patch)
241 result = _invoke(root, str(patch_file), "--json")
242 assert result.exit_code == 0
243 data = json.loads(result.stdout)
244 assert "applied" in data
245 assert "failed" in data
246 assert "a.py" in data["applied"]
247
248
249 def test_apply_multiple_files(tmp_path: pathlib.Path) -> None:
250 root = _init_repo(tmp_path)
251 (root / "a.py").write_text("x = 1\n", encoding="utf-8")
252 (root / "b.py").write_text("y = 1\n", encoding="utf-8")
253 patch = (
254 _make_simple_patch("a.py", ["x = 1\n"], ["x = 2\n"])
255 + "\n"
256 + _make_simple_patch("b.py", ["y = 1\n"], ["y = 2\n"])
257 )
258 patch_file = tmp_path / "multi.patch"
259 patch_file.write_text(patch)
260 result = _invoke(root, str(patch_file), "--json")
261 assert result.exit_code == 0
262 data = json.loads(result.stdout)
263 assert "a.py" in data["applied"]
264 assert "b.py" in data["applied"]
265
266
267 # ---------------------------------------------------------------------------
268 # Integration — --check mode
269 # ---------------------------------------------------------------------------
270
271
272 def test_apply_check_does_not_modify_file(tmp_path: pathlib.Path) -> None:
273 root = _init_repo(tmp_path)
274 (root / "a.py").write_text("x = 1\n", encoding="utf-8")
275 patch = _make_simple_patch("a.py", ["x = 1\n"], ["x = 2\n"])
276 patch_file = tmp_path / "change.patch"
277 patch_file.write_text(patch)
278 result = _invoke(root, str(patch_file), "--check")
279 assert result.exit_code == 0
280 # File must be unchanged
281 assert (root / "a.py").read_text() == "x = 1\n"
282
283
284 def test_apply_check_exits_nonzero_on_conflict(tmp_path: pathlib.Path) -> None:
285 root = _init_repo(tmp_path)
286 (root / "a.py").write_text("completely different content\n", encoding="utf-8")
287 # Patch expects "x = 1" but file has different content
288 patch = _make_simple_patch("a.py", ["x = 1\n"], ["x = 2\n"])
289 patch_file = tmp_path / "conflict.patch"
290 patch_file.write_text(patch)
291 result = _invoke(root, str(patch_file), "--check")
292 assert result.exit_code != 0
293
294
295 # ---------------------------------------------------------------------------
296 # Integration — format-patch → apply round-trip
297 # ---------------------------------------------------------------------------
298
299
300 def test_format_patch_apply_roundtrip(tmp_path: pathlib.Path) -> None:
301 """format-patch output can be fed into apply and produces correct result."""
302 # Use format-patch to produce a patch file
303 from tests.cli_test_helper import CliRunner as CR
304 cr = CR()
305
306 root = _init_repo(tmp_path)
307 _commit_files(root, {"a.py": b"x = 1\n"}, message="initial")
308 _commit_files(root, {"a.py": b"x = 2\n"}, message="change x")
309
310 out_dir = tmp_path / "patches"
311 out_dir.mkdir()
312 from muse.cli.app import main as cli
313 cr.invoke(cli, ["format-patch", "HEAD", "--output-dir", str(out_dir)], env=_env(root))
314
315 # Now reset the file to the old content and apply the patch
316 (root / "a.py").write_text("x = 1\n", encoding="utf-8")
317 patch_file = next(out_dir.glob("*.patch"))
318 result = _invoke(root, str(patch_file))
319 assert result.exit_code == 0
320 assert (root / "a.py").read_text() == "x = 2\n"
321
322
323 # ---------------------------------------------------------------------------
324 # Security — path traversal in patch headers
325 # ---------------------------------------------------------------------------
326
327
328 def test_apply_rejects_path_traversal_in_patch(tmp_path: pathlib.Path) -> None:
329 root = _init_repo(tmp_path)
330 # Craft a patch with a traversal path
331 traversal_patch = textwrap.dedent("""\
332 --- a/../../../tmp/evil.py
333 +++ b/../../../tmp/evil.py
334 @@ -0,0 +1 @@
335 +evil content
336 """)
337 patch_file = tmp_path / "evil.patch"
338 patch_file.write_text(traversal_patch)
339 result = _invoke(root, str(patch_file))
340 assert result.exit_code != 0
341
342
343 def test_apply_rejects_muse_internal_paths(tmp_path: pathlib.Path) -> None:
344 root = _init_repo(tmp_path)
345 muse_patch = textwrap.dedent("""\
346 --- a/.muse/config.toml
347 +++ b/.muse/config.toml
348 @@ -0,0 +1 @@
349 +evil = true
350 """)
351 patch_file = tmp_path / "muse.patch"
352 patch_file.write_text(muse_patch)
353 result = _invoke(root, str(patch_file))
354 assert result.exit_code != 0
355
356
357 # ---------------------------------------------------------------------------
358 # Stress — large hunk
359 # ---------------------------------------------------------------------------
360
361
362 def test_apply_large_hunk(tmp_path: pathlib.Path) -> None:
363 """A 50-line file with a change in the middle applies correctly."""
364 root = _init_repo(tmp_path)
365 original = [f"line {i}\n" for i in range(50)]
366 modified = original[:25] + ["CHANGED\n"] + original[26:]
367 (root / "big.py").write_text("".join(original), encoding="utf-8")
368 patch = _make_simple_patch("big.py", original, modified)
369 patch_file = tmp_path / "big.patch"
370 patch_file.write_text(patch)
371 result = _invoke(root, str(patch_file))
372 assert result.exit_code == 0
373 result_lines = (root / "big.py").read_text().splitlines(keepends=True)
374 assert result_lines[25] == "CHANGED\n"
375 assert result_lines[0] == "line 0\n"
376 assert result_lines[49] == "line 49\n"
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 145 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 148 days ago