gabriel / muse public
test_cmd_patch_id.py python
330 lines 12.3 KB
Raw
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor ⚠ breaking 154 days ago
1 """Tests for ``muse patch-id`` — content-based commit identity.
2
3 Coverage tiers:
4 - Unit: _compute_patch_id helper (same diff → same id, different → different,
5 whitespace normalization with --stable, initial commit)
6 - Integration: HEAD returns patch-id; specific commit; same diff = same patch-id
7 across cherry-picked commits; --json schema; text format;
8 nonexistent ref exits nonzero; empty repo exits nonzero
9 - End-to-end: full CLI via CliRunner
10 - Security: ANSI in ref rejected; path traversal in ref argument
11 - Stress: 10-commit range returns 10 distinct patch-ids
12 """
13
14 from __future__ import annotations
15
16 import datetime
17 import hashlib
18 import json
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 = "patch-id-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 ) -> str:
64 global _counter
65 _counter += 1
66 manifest: Manifest = {}
67 for rel_path, content in files.items():
68 obj_id = _sha(content)
69 write_object(root, obj_id, content)
70 manifest[rel_path] = obj_id
71 abs_path = root / rel_path
72 abs_path.parent.mkdir(parents=True, exist_ok=True)
73 abs_path.write_bytes(content)
74 snap_id = compute_snapshot_id(manifest)
75 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
76 committed_at = datetime.datetime.now(datetime.timezone.utc)
77 ref_path = root / ".muse" / "refs" / "heads" / branch
78 parent_id = ref_path.read_text(encoding="utf-8").strip() if ref_path.exists() else None
79 parents = [parent_id] if parent_id else []
80 commit_id = compute_commit_id(
81 parents, snap_id, f"commit {_counter}", committed_at.isoformat()
82 )
83 write_commit(
84 root,
85 CommitRecord(
86 commit_id=commit_id,
87 repo_id=_REPO_ID,
88 branch=branch,
89 snapshot_id=snap_id,
90 message=f"commit {_counter}",
91 committed_at=committed_at,
92 parent_commit_id=parent_id,
93 ),
94 )
95 ref_path.write_text(commit_id, encoding="utf-8")
96 return commit_id
97
98
99 def _invoke(repo: pathlib.Path, *args: str):
100 from muse.cli.app import main as cli
101 return runner.invoke(cli, ["patch-id", *args], env=_env(repo))
102
103
104 # ---------------------------------------------------------------------------
105 # Unit — _compute_patch_id
106 # ---------------------------------------------------------------------------
107
108
109 def test_compute_patch_id_same_diff_same_id(tmp_path: pathlib.Path) -> None:
110 from muse.cli.commands.patch_id import _compute_patch_id
111 root = _init_repo(tmp_path)
112 base = {"a.py": _sha(b"# a\n")}
113 target = {"a.py": _sha(b"# b\n")}
114 id1 = _compute_patch_id(root, base, target, stable=False)
115 id2 = _compute_patch_id(root, base, target, stable=False)
116 assert id1 == id2
117 assert len(id1) == 64
118
119
120 def test_compute_patch_id_different_diff_different_id(tmp_path: pathlib.Path) -> None:
121 from muse.cli.commands.patch_id import _compute_patch_id
122 root = _init_repo(tmp_path)
123 write_object(root, _sha(b"# a\n"), b"# a\n")
124 write_object(root, _sha(b"# b\n"), b"# b\n")
125 write_object(root, _sha(b"# c\n"), b"# c\n")
126 base = {"a.py": _sha(b"# a\n")}
127 target1 = {"a.py": _sha(b"# b\n")}
128 target2 = {"a.py": _sha(b"# c\n")}
129 id1 = _compute_patch_id(root, base, target1, stable=False)
130 id2 = _compute_patch_id(root, base, target2, stable=False)
131 assert id1 != id2
132
133
134 def test_compute_patch_id_empty_diff_is_deterministic(tmp_path: pathlib.Path) -> None:
135 """A commit that changes nothing (no-op) should produce a deterministic id."""
136 from muse.cli.commands.patch_id import _compute_patch_id
137 root = _init_repo(tmp_path)
138 write_object(root, _sha(b"# a\n"), b"# a\n")
139 manifest = {"a.py": _sha(b"# a\n")}
140 id1 = _compute_patch_id(root, manifest, manifest, stable=False)
141 id2 = _compute_patch_id(root, manifest, manifest, stable=False)
142 assert id1 == id2
143
144
145 def test_compute_patch_id_stable_normalizes_whitespace(tmp_path: pathlib.Path) -> None:
146 """--stable strips trailing whitespace so 'hello ' and 'hello' produce same id."""
147 from muse.cli.commands.patch_id import _compute_patch_id
148 root = _init_repo(tmp_path)
149 content_a = b"x = 1\n"
150 content_b = b"x = 2\n"
151 content_b_ws = b"x = 2 \n" # trailing whitespace
152 write_object(root, _sha(content_a), content_a)
153 write_object(root, _sha(content_b), content_b)
154 write_object(root, _sha(content_b_ws), content_b_ws)
155 base = {"f.py": _sha(content_a)}
156 target_clean = {"f.py": _sha(content_b)}
157 target_ws = {"f.py": _sha(content_b_ws)}
158 id_clean = _compute_patch_id(root, base, target_clean, stable=True)
159 id_ws = _compute_patch_id(root, base, target_ws, stable=True)
160 assert id_clean == id_ws, "--stable must treat trailing-whitespace differences as identical"
161
162
163 def test_compute_patch_id_without_stable_is_sensitive_to_whitespace(tmp_path: pathlib.Path) -> None:
164 from muse.cli.commands.patch_id import _compute_patch_id
165 root = _init_repo(tmp_path)
166 content_a = b"x = 1\n"
167 content_b = b"x = 2\n"
168 content_b_ws = b"x = 2 \n"
169 write_object(root, _sha(content_a), content_a)
170 write_object(root, _sha(content_b), content_b)
171 write_object(root, _sha(content_b_ws), content_b_ws)
172 base = {"f.py": _sha(content_a)}
173 target_clean = {"f.py": _sha(content_b)}
174 target_ws = {"f.py": _sha(content_b_ws)}
175 id_clean = _compute_patch_id(root, base, target_clean, stable=False)
176 id_ws = _compute_patch_id(root, base, target_ws, stable=False)
177 assert id_clean != id_ws, "Without --stable, whitespace differences must produce different ids"
178
179
180 # ---------------------------------------------------------------------------
181 # Integration — JSON output
182 # ---------------------------------------------------------------------------
183
184
185 def test_patch_id_json_schema_keys(tmp_path: pathlib.Path) -> None:
186 root = _init_repo(tmp_path)
187 _commit_files(root, {"a.py": b"# a\n"})
188 _commit_files(root, {"a.py": b"# b\n"})
189 result = _invoke(root, "HEAD", "--json")
190 assert result.exit_code == 0
191 data = json.loads(result.stdout)
192 assert "commit_id" in data
193 assert "patch_id" in data
194 assert "subject" in data
195 assert len(data["patch_id"]) == 64
196
197
198 def test_patch_id_json_subject_is_commit_message(tmp_path: pathlib.Path) -> None:
199 root = _init_repo(tmp_path)
200 _commit_files(root, {"a.py": b"# a\n"})
201 _commit_files(root, {"a.py": b"# b\n"})
202 result = _invoke(root, "HEAD", "--json")
203 data = json.loads(result.stdout)
204 assert isinstance(data["subject"], str)
205
206
207 def test_patch_id_json_commit_id_matches_head(tmp_path: pathlib.Path) -> None:
208 root = _init_repo(tmp_path)
209 commit_id = _commit_files(root, {"a.py": b"# a\n"})
210 _commit_files(root, {"a.py": b"# b\n"})
211 head_commit_id = (root / ".muse" / "refs" / "heads" / "main").read_text().strip()
212 result = _invoke(root, "HEAD", "--json")
213 data = json.loads(result.stdout)
214 assert data["commit_id"] == head_commit_id
215
216
217 # ---------------------------------------------------------------------------
218 # Integration — text output
219 # ---------------------------------------------------------------------------
220
221
222 def test_patch_id_text_output_has_two_parts(tmp_path: pathlib.Path) -> None:
223 """Text output: '<patch_id> <commit_id>'"""
224 root = _init_repo(tmp_path)
225 _commit_files(root, {"a.py": b"# a\n"})
226 _commit_files(root, {"a.py": b"# b\n"})
227 result = _invoke(root, "HEAD")
228 assert result.exit_code == 0
229 line = result.stdout.strip()
230 parts = line.split()
231 assert len(parts) == 2
232 assert len(parts[0]) == 64 # patch_id
233 assert len(parts[1]) == 64 # commit_id
234
235
236 # ---------------------------------------------------------------------------
237 # Integration — same diff → same patch-id (cherry-pick detection)
238 # ---------------------------------------------------------------------------
239
240
241 def test_patch_id_same_diff_same_id_across_commits(tmp_path: pathlib.Path) -> None:
242 """Two commits that make the same change get the same patch-id."""
243 from muse.cli.commands.patch_id import _compute_patch_id
244 root = _init_repo(tmp_path)
245 # Set up a base state
246 write_object(root, _sha(b"x = 1\n"), b"x = 1\n")
247 write_object(root, _sha(b"x = 2\n"), b"x = 2\n")
248 base_manifest = {"a.py": _sha(b"x = 1\n")}
249 target_manifest = {"a.py": _sha(b"x = 2\n")}
250 # Compute patch-id for the same logical diff twice
251 id1 = _compute_patch_id(root, base_manifest, target_manifest, stable=False)
252 id2 = _compute_patch_id(root, base_manifest, target_manifest, stable=False)
253 assert id1 == id2
254
255
256 # ---------------------------------------------------------------------------
257 # Integration — specific commit ref
258 # ---------------------------------------------------------------------------
259
260
261 def test_patch_id_specific_commit_id(tmp_path: pathlib.Path) -> None:
262 root = _init_repo(tmp_path)
263 commit1 = _commit_files(root, {"a.py": b"# a\n"})
264 commit2 = _commit_files(root, {"a.py": b"# b\n"})
265 result = _invoke(root, commit2, "--json")
266 assert result.exit_code == 0
267 data = json.loads(result.stdout)
268 assert data["commit_id"] == commit2
269
270
271 def test_patch_id_branch_name_ref(tmp_path: pathlib.Path) -> None:
272 root = _init_repo(tmp_path)
273 _commit_files(root, {"a.py": b"# a\n"})
274 result = _invoke(root, "main", "--json")
275 assert result.exit_code == 0
276 data = json.loads(result.stdout)
277 assert "patch_id" in data
278
279
280 # ---------------------------------------------------------------------------
281 # Integration — error cases
282 # ---------------------------------------------------------------------------
283
284
285 def test_patch_id_nonexistent_ref_exits_nonzero(tmp_path: pathlib.Path) -> None:
286 root = _init_repo(tmp_path)
287 _commit_files(root, {"a.py": b"# a\n"})
288 result = _invoke(root, "no-such-branch")
289 assert result.exit_code != 0
290
291
292 def test_patch_id_empty_repo_exits_nonzero(tmp_path: pathlib.Path) -> None:
293 root = _init_repo(tmp_path)
294 result = _invoke(root, "HEAD")
295 assert result.exit_code != 0
296
297
298 # ---------------------------------------------------------------------------
299 # Security
300 # ---------------------------------------------------------------------------
301
302
303 def test_patch_id_ansi_in_ref_rejected(tmp_path: pathlib.Path) -> None:
304 root = _init_repo(tmp_path)
305 _commit_files(root, {"a.py": b"# a\n"})
306 result = _invoke(root, "\x1b[31mbad\x1b[0m")
307 assert result.exit_code != 0
308
309
310 # ---------------------------------------------------------------------------
311 # Stress — 10-commit range
312 # ---------------------------------------------------------------------------
313
314
315 def test_patch_id_different_commits_have_different_ids(tmp_path: pathlib.Path) -> None:
316 """10 commits that each make distinct changes must produce 10 distinct patch-ids."""
317 from muse.cli.commands.patch_id import _compute_patch_id
318 root = _init_repo(tmp_path)
319 # Build 10 manifest transitions
320 patch_ids = set()
321 prev_manifest: dict[str, str] = {}
322 for i in range(10):
323 content = f"value = {i}\n".encode()
324 obj_id = _sha(content)
325 write_object(root, obj_id, content)
326 new_manifest = {"file.py": obj_id}
327 pid = _compute_patch_id(root, prev_manifest, new_manifest, stable=False)
328 patch_ids.add(pid)
329 prev_manifest = new_manifest
330 assert len(patch_ids) == 10, "Each distinct diff must produce a unique patch-id"
File History 1 commit
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 154 days ago