gabriel / muse public
test_cmd_format_patch.py python
355 lines 12.8 KB
Raw
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor ⚠ breaking 149 days ago
1 """Tests for ``muse format-patch`` — export commits as .patch files.
2
3 Coverage tiers:
4 - Unit: _make_patch_filename, _format_patch_content, _resolve_commit_range
5 - Integration: single commit → one .patch file; range → multiple files; -N form;
6 --stdout mode; --json manifest; numbered naming (0001-, 0002-);
7 provenance headers present; empty/no-change commits; --output-dir
8 - End-to-end: full CLI via CliRunner
9 - Security: malicious subject sanitized in filename; output-dir traversal rejected
10 - Stress: 10-commit range produces 10 patch files
11 """
12
13 from __future__ import annotations
14
15 import datetime
16 import hashlib
17 import json
18 import os
19 import pathlib
20
21 import pytest
22
23 from tests.cli_test_helper import CliRunner
24 from muse.core.object_store import write_object
25 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
26 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
27 from muse.core._types import Manifest
28
29 runner = CliRunner()
30
31 _REPO_ID = "format-patch-test"
32 _counter = 0
33
34
35 # ---------------------------------------------------------------------------
36 # Helpers
37 # ---------------------------------------------------------------------------
38
39
40 def _sha(data: bytes) -> str:
41 return hashlib.sha256(data).hexdigest()
42
43
44 def _init_repo(path: pathlib.Path) -> pathlib.Path:
45 muse = path / ".muse"
46 for d in ("commits", "snapshots", "objects", "refs/heads", "code"):
47 (muse / d).mkdir(parents=True, exist_ok=True)
48 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
49 (muse / "repo.json").write_text(
50 json.dumps({"repo_id": _REPO_ID, "domain": "code"}), encoding="utf-8"
51 )
52 return path
53
54
55 def _env(repo: pathlib.Path) -> dict[str, str]:
56 return {"MUSE_REPO_ROOT": str(repo)}
57
58
59 def _commit_files(
60 root: pathlib.Path,
61 files: dict[str, bytes],
62 branch: str = "main",
63 message: str | None = None,
64 ) -> str:
65 global _counter
66 _counter += 1
67 manifest: Manifest = {}
68 for rel_path, content in files.items():
69 obj_id = _sha(content)
70 write_object(root, obj_id, content)
71 manifest[rel_path] = obj_id
72 abs_path = root / rel_path
73 abs_path.parent.mkdir(parents=True, exist_ok=True)
74 abs_path.write_bytes(content)
75 snap_id = compute_snapshot_id(manifest)
76 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
77 committed_at = datetime.datetime.now(datetime.timezone.utc)
78 ref_path = root / ".muse" / "refs" / "heads" / branch
79 parent_id = ref_path.read_text(encoding="utf-8").strip() if ref_path.exists() else None
80 parents = [parent_id] if parent_id else []
81 msg = message or f"commit {_counter}"
82 commit_id = compute_commit_id(
83 parents, snap_id, msg, committed_at.isoformat()
84 )
85 write_commit(
86 root,
87 CommitRecord(
88 commit_id=commit_id,
89 repo_id=_REPO_ID,
90 branch=branch,
91 snapshot_id=snap_id,
92 message=msg,
93 committed_at=committed_at,
94 parent_commit_id=parent_id,
95 ),
96 )
97 ref_path.write_text(commit_id, encoding="utf-8")
98 return commit_id
99
100
101 def _invoke(repo: pathlib.Path, *args: str):
102 from muse.cli.app import main as cli
103 return runner.invoke(cli, ["format-patch", *args], env=_env(repo))
104
105
106 # ---------------------------------------------------------------------------
107 # Unit — _make_patch_filename
108 # ---------------------------------------------------------------------------
109
110
111 def test_make_patch_filename_numbered(tmp_path: pathlib.Path) -> None:
112 from muse.cli.commands.format_patch import _make_patch_filename
113 name = _make_patch_filename(1, "feat: add login")
114 assert name.startswith("0001-")
115 assert name.endswith(".patch")
116
117
118 def test_make_patch_filename_high_number(tmp_path: pathlib.Path) -> None:
119 from muse.cli.commands.format_patch import _make_patch_filename
120 name = _make_patch_filename(42, "fix: typo")
121 assert name.startswith("0042-")
122
123
124 def test_make_patch_filename_sanitizes_subject(tmp_path: pathlib.Path) -> None:
125 from muse.cli.commands.format_patch import _make_patch_filename
126 # Slashes should be replaced so the filename is safe
127 name = _make_patch_filename(1, "feat: add ../etc/passwd injection")
128 assert "/" not in name
129 assert ".." not in name
130
131
132 def test_make_patch_filename_strips_control_chars(tmp_path: pathlib.Path) -> None:
133 from muse.cli.commands.format_patch import _make_patch_filename
134 name = _make_patch_filename(1, "bad\x00name\x1b[31m")
135 # No control characters in the filename
136 assert all(ord(c) >= 32 for c in name)
137
138
139 def test_make_patch_filename_max_length(tmp_path: pathlib.Path) -> None:
140 from muse.cli.commands.format_patch import _make_patch_filename
141 long_subject = "a" * 200
142 name = _make_patch_filename(1, long_subject)
143 assert len(name) <= 80 # reasonable max for filesystem compatibility
144
145
146 # ---------------------------------------------------------------------------
147 # Unit — _format_patch_content
148 # ---------------------------------------------------------------------------
149
150
151 def test_format_patch_content_has_required_headers(tmp_path: pathlib.Path) -> None:
152 from muse.cli.commands.format_patch import _format_patch_content
153 root = _init_repo(tmp_path)
154 write_object(root, _sha(b"x = 1\n"), b"x = 1\n")
155 write_object(root, _sha(b"x = 2\n"), b"x = 2\n")
156 base = {"a.py": _sha(b"x = 1\n")}
157 target = {"a.py": _sha(b"x = 2\n")}
158 committed_at = datetime.datetime.now(datetime.timezone.utc)
159 content = _format_patch_content(
160 root=root,
161 commit_id="abc" * 21 + "d",
162 subject="feat: change x",
163 committed_at=committed_at,
164 base_manifest=base,
165 target_manifest=target,
166 )
167 assert "Subject:" in content
168 assert "X-Muse-Commit-ID:" in content
169 assert "diff --muse" in content or "---" in content
170
171
172 def test_format_patch_content_includes_diff_lines(tmp_path: pathlib.Path) -> None:
173 from muse.cli.commands.format_patch import _format_patch_content
174 root = _init_repo(tmp_path)
175 write_object(root, _sha(b"x = 1\n"), b"x = 1\n")
176 write_object(root, _sha(b"x = 2\n"), b"x = 2\n")
177 base = {"a.py": _sha(b"x = 1\n")}
178 target = {"a.py": _sha(b"x = 2\n")}
179 committed_at = datetime.datetime.now(datetime.timezone.utc)
180 content = _format_patch_content(
181 root=root,
182 commit_id="abc" * 21 + "d",
183 subject="test",
184 committed_at=committed_at,
185 base_manifest=base,
186 target_manifest=target,
187 )
188 assert "+x = 2" in content
189 assert "-x = 1" in content
190
191
192 # ---------------------------------------------------------------------------
193 # Integration — single commit output
194 # ---------------------------------------------------------------------------
195
196
197 def test_format_patch_single_commit_creates_file(tmp_path: pathlib.Path) -> None:
198 root = _init_repo(tmp_path)
199 _commit_files(root, {"a.py": b"x = 1\n"}, message="initial")
200 _commit_files(root, {"a.py": b"x = 2\n"}, message="feat: bump x")
201 out_dir = tmp_path / "patches"
202 out_dir.mkdir()
203 result = _invoke(root, "HEAD", "--output-dir", str(out_dir))
204 assert result.exit_code == 0
205 patch_files = list(out_dir.glob("*.patch"))
206 assert len(patch_files) == 1
207
208
209 def test_format_patch_single_commit_numbered_filename(tmp_path: pathlib.Path) -> None:
210 root = _init_repo(tmp_path)
211 _commit_files(root, {"a.py": b"x = 1\n"}, message="initial")
212 _commit_files(root, {"a.py": b"x = 2\n"}, message="feat: bump x")
213 out_dir = tmp_path / "patches"
214 out_dir.mkdir()
215 _invoke(root, "HEAD", "--output-dir", str(out_dir))
216 patch_files = list(out_dir.glob("*.patch"))
217 assert patch_files[0].name.startswith("0001-")
218
219
220 def test_format_patch_file_has_muse_commit_id_header(tmp_path: pathlib.Path) -> None:
221 root = _init_repo(tmp_path)
222 _commit_files(root, {"a.py": b"x = 1\n"}, message="initial")
223 commit_id = _commit_files(root, {"a.py": b"x = 2\n"}, message="feat: change")
224 out_dir = tmp_path / "patches"
225 out_dir.mkdir()
226 _invoke(root, "HEAD", "--output-dir", str(out_dir))
227 patch_file = next(out_dir.glob("*.patch"))
228 content = patch_file.read_text()
229 assert "X-Muse-Commit-ID:" in content
230 assert commit_id[:8] in content or commit_id in content
231
232
233 def test_format_patch_file_has_diff_content(tmp_path: pathlib.Path) -> None:
234 root = _init_repo(tmp_path)
235 _commit_files(root, {"a.py": b"x = 1\n"}, message="initial")
236 _commit_files(root, {"a.py": b"x = 2\n"}, message="change x")
237 out_dir = tmp_path / "patches"
238 out_dir.mkdir()
239 _invoke(root, "HEAD", "--output-dir", str(out_dir))
240 content = next(out_dir.glob("*.patch")).read_text()
241 assert "+x = 2" in content
242 assert "-x = 1" in content
243
244
245 # ---------------------------------------------------------------------------
246 # Integration — --stdout
247 # ---------------------------------------------------------------------------
248
249
250 def test_format_patch_stdout_contains_patch_content(tmp_path: pathlib.Path) -> None:
251 root = _init_repo(tmp_path)
252 _commit_files(root, {"a.py": b"x = 1\n"}, message="initial")
253 _commit_files(root, {"a.py": b"x = 2\n"}, message="feat: change")
254 result = _invoke(root, "HEAD", "--stdout")
255 assert result.exit_code == 0
256 assert "Subject:" in result.stdout
257 assert "+x = 2" in result.stdout
258
259
260 # ---------------------------------------------------------------------------
261 # Integration — --json
262 # ---------------------------------------------------------------------------
263
264
265 def test_format_patch_json_schema(tmp_path: pathlib.Path) -> None:
266 root = _init_repo(tmp_path)
267 _commit_files(root, {"a.py": b"x = 1\n"}, message="initial")
268 _commit_files(root, {"a.py": b"x = 2\n"}, message="feat: change")
269 out_dir = tmp_path / "patches"
270 out_dir.mkdir()
271 result = _invoke(root, "HEAD", "--output-dir", str(out_dir), "--json")
272 assert result.exit_code == 0
273 data = json.loads(result.stdout)
274 assert "patches" in data
275 assert len(data["patches"]) >= 1
276 patch = data["patches"][0]
277 assert "file" in patch
278 assert "commit_id" in patch
279 assert "subject" in patch
280
281
282 # ---------------------------------------------------------------------------
283 # Integration — empty repo error
284 # ---------------------------------------------------------------------------
285
286
287 def test_format_patch_empty_repo_exits_nonzero(tmp_path: pathlib.Path) -> None:
288 root = _init_repo(tmp_path)
289 result = _invoke(root, "HEAD")
290 assert result.exit_code != 0
291
292
293 # ---------------------------------------------------------------------------
294 # Integration — initial commit (no parent)
295 # ---------------------------------------------------------------------------
296
297
298 def test_format_patch_initial_commit_no_parent(tmp_path: pathlib.Path) -> None:
299 root = _init_repo(tmp_path)
300 _commit_files(root, {"a.py": b"x = 1\n"}, message="initial commit")
301 out_dir = tmp_path / "patches"
302 out_dir.mkdir()
303 result = _invoke(root, "HEAD", "--output-dir", str(out_dir))
304 assert result.exit_code == 0
305 patch_files = list(out_dir.glob("*.patch"))
306 assert len(patch_files) == 1
307 # Initial commit diff: all files are additions
308 content = patch_files[0].read_text()
309 assert "+x = 1" in content
310
311
312 # ---------------------------------------------------------------------------
313 # Security — filename injection
314 # ---------------------------------------------------------------------------
315
316
317 def test_format_patch_malicious_subject_safe_filename(tmp_path: pathlib.Path) -> None:
318 root = _init_repo(tmp_path)
319 _commit_files(root, {"a.py": b"x = 1\n"}, message="initial")
320 _commit_files(
321 root, {"a.py": b"x = 2\n"},
322 message="feat: ../../../etc/malicious\x00\x1b[31m",
323 )
324 out_dir = tmp_path / "patches"
325 out_dir.mkdir()
326 result = _invoke(root, "HEAD", "--output-dir", str(out_dir))
327 # Should succeed and produce a safe filename
328 assert result.exit_code == 0
329 for f in out_dir.glob("*.patch"):
330 assert "/" not in f.name
331 assert ".." not in f.name
332 assert all(ord(c) >= 32 for c in f.name)
333
334
335 # ---------------------------------------------------------------------------
336 # Stress — 10 commits
337 # ---------------------------------------------------------------------------
338
339
340 def test_format_patch_10_commits_range(tmp_path: pathlib.Path) -> None:
341 """HEAD~9..HEAD produces 9 patch files (last 9 commits, each distinct)."""
342 root = _init_repo(tmp_path)
343 # Create 10 commits
344 for i in range(10):
345 _commit_files(root, {"a.py": f"x = {i}\n".encode()}, message=f"commit {i}")
346 out_dir = tmp_path / "patches"
347 out_dir.mkdir()
348 # Use -N form for last 9 commits
349 result = _invoke(root, "-9", "--output-dir", str(out_dir), "--json")
350 assert result.exit_code == 0
351 data = json.loads(result.stdout)
352 assert len(data["patches"]) == 9
353 # Each file should be uniquely numbered
354 filenames = [p["file"] for p in data["patches"]]
355 assert len(set(filenames)) == 9
File History 1 commit
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 149 days ago