gabriel / muse public
test_cmd_rev_parse.py python
402 lines 15.6 KB
Raw
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor ⚠ breaking 150 days ago
1 """Comprehensive tests for ``muse rev-parse``.
2
3 Coverage tiers
4 --------------
5 - Integration: branch, HEAD, SHA prefix, full SHA, --abbrev-ref, --format text
6 - Edge cases: empty repo (no commits), empty ref, ambiguous prefix, HEAD→branch
7 - Security: ANSI/control chars in ref → JSON-escaped, empty ref clean error
8 - Stress: 200 rapid resolves
9 """
10 from __future__ import annotations
11
12 import datetime
13 import json
14 import pathlib
15
16 import pytest
17 from muse.core.errors import ExitCode
18 from muse.core.object_store import write_object
19 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
20 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
21 from muse.core._types import Manifest
22 from tests.cli_test_helper import CliRunner, InvokeResult
23
24 runner = CliRunner()
25
26 # ---------------------------------------------------------------------------
27 # Helpers
28 # ---------------------------------------------------------------------------
29
30 def _make_repo(tmp_path: pathlib.Path, branch: str = "main") -> pathlib.Path:
31 repo = tmp_path / "repo"
32 muse = repo / ".muse"
33 for sub in ("objects", "commits", "snapshots", "refs/heads"):
34 (muse / sub).mkdir(parents=True)
35 (muse / "HEAD").write_text(f"ref: refs/heads/{branch}")
36 (muse / "repo.json").write_text(json.dumps({"repo_id": "test", "domain": "code"}))
37 return repo
38
39
40 _TS = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
41
42
43 def _store_snap(repo: pathlib.Path, manifest: Manifest | None = None) -> str:
44 sid = compute_snapshot_id(manifest or {})
45 write_snapshot(repo, SnapshotRecord(
46 snapshot_id=sid,
47 manifest=manifest or {},
48 created_at=_TS,
49 ))
50 return sid
51
52
53 def _make_commit(
54 repo: pathlib.Path,
55 snapshot_id: str,
56 *,
57 branch: str = "main",
58 parent: str | None = None,
59 message: str = "test",
60 ) -> str:
61 parents = [parent] if parent else []
62 cid = compute_commit_id(parents, snapshot_id, message, _TS.isoformat())
63 rec = CommitRecord(
64 commit_id=cid,
65 repo_id="test-repo-id",
66 branch=branch,
67 snapshot_id=snapshot_id,
68 message=message,
69 committed_at=_TS,
70 author="tester",
71 parent_commit_id=parent,
72 )
73 write_commit(repo, rec)
74 return cid
75
76
77 def _set_head(repo: pathlib.Path, branch: str, commit_id: str) -> None:
78 ref = repo / ".muse" / "refs" / "heads" / branch
79 ref.parent.mkdir(parents=True, exist_ok=True)
80 ref.write_text(commit_id)
81
82
83 def _rev(repo: pathlib.Path, *args: str) -> InvokeResult:
84 from muse.cli.app import main as cli
85 return runner.invoke(
86 cli,
87 ["rev-parse", *args],
88 env={"MUSE_REPO_ROOT": str(repo)},
89 )
90
91
92 def _populated_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]:
93 """Return (repo, commit_id) with one commit on main, using real content-addressed IDs."""
94 repo = _make_repo(tmp_path)
95 sid = _store_snap(repo)
96 cid = _make_commit(repo, sid)
97 _set_head(repo, "main", cid)
98 return repo, cid
99
100
101 # ---------------------------------------------------------------------------
102 # Integration — branch resolution
103 # ---------------------------------------------------------------------------
104
105
106 # ---------------------------------------------------------------------------
107 # New: default format is text, --json makes it meaningful
108 # ---------------------------------------------------------------------------
109
110
111 class TestDefaultFormat:
112 def test_default_output_is_plain_text(self, tmp_path: pathlib.Path) -> None:
113 """Without --json the output must be plain text (just the commit ID)."""
114 repo, cid = _populated_repo(tmp_path)
115 result = _rev(repo, "main")
116 assert result.exit_code == 0
117 assert result.output.strip() == cid
118 with pytest.raises((json.JSONDecodeError, ValueError)):
119 json.loads(result.output)
120
121 def test_json_flag_gives_dict_output(self, tmp_path: pathlib.Path) -> None:
122 """With --json output is a dict; without it text."""
123 repo, cid = _populated_repo(tmp_path)
124 result = _rev(repo, "--json", "main")
125 assert result.exit_code == 0
126 data = json.loads(result.output)
127 assert data["commit_id"] == cid
128 assert data["ref"] == "main"
129
130 def test_format_text_explicit(self, tmp_path: pathlib.Path) -> None:
131 repo, cid = _populated_repo(tmp_path)
132 result = _rev(repo, "--format", "text", "main")
133 assert result.exit_code == 0
134 assert result.output.strip() == cid
135
136
137 # ---------------------------------------------------------------------------
138 # New: sha256: prefix is required; bare hex is rejected
139 # ---------------------------------------------------------------------------
140
141
142 class TestSha256PrefixRequired:
143 def test_bare_full_hex_rejected(self, tmp_path: pathlib.Path) -> None:
144 """64-char bare hex without sha256: prefix must be rejected."""
145 repo, cid = _populated_repo(tmp_path)
146 result = _rev(repo, cid.removeprefix("sha256:"))
147 assert result.exit_code == ExitCode.USER_ERROR
148 data = json.loads(result.output)
149 assert "sha256:" in data["error"]
150
151 def test_bare_short_hex_rejected(self, tmp_path: pathlib.Path) -> None:
152 """Short bare hex without sha256: prefix must be rejected."""
153 repo, cid = _populated_repo(tmp_path)
154 result = _rev(repo, cid[7:15]) # 8 bare hex chars
155 assert result.exit_code == ExitCode.USER_ERROR
156 data = json.loads(result.output)
157 assert "sha256:" in data["error"]
158
159 def test_canonical_full_id_resolves(self, tmp_path: pathlib.Path) -> None:
160 """sha256:<64hex> must resolve to the commit."""
161 repo, cid = _populated_repo(tmp_path)
162 result = _rev(repo, "--json", cid)
163 assert result.exit_code == 0
164 assert json.loads(result.output)["commit_id"] == cid
165
166 def test_canonical_prefix_resolves(self, tmp_path: pathlib.Path) -> None:
167 """sha256:<8hex> prefix must resolve to the commit."""
168 repo, cid = _populated_repo(tmp_path)
169 prefix = "sha256:" + cid[7:15] # sha256: + 8 hex chars
170 result = _rev(repo, "--json", prefix)
171 assert result.exit_code == 0
172 assert json.loads(result.output)["commit_id"] == cid
173
174
175 # ---------------------------------------------------------------------------
176 # Integration — branch resolution
177 # ---------------------------------------------------------------------------
178
179
180 class TestBranchResolution:
181 def test_resolve_branch_json(self, tmp_path: pathlib.Path) -> None:
182 repo, cid = _populated_repo(tmp_path)
183 result = _rev(repo, "--json", "main")
184 assert result.exit_code == 0
185 data = json.loads(result.output)
186 assert data["commit_id"] == cid
187 assert data["ref"] == "main"
188
189 def test_resolve_branch_text(self, tmp_path: pathlib.Path) -> None:
190 repo, cid = _populated_repo(tmp_path)
191 result = _rev(repo, "--format", "text", "main")
192 assert result.exit_code == 0
193 assert result.output.strip() == cid
194
195 def test_json_flag_shorthand(self, tmp_path: pathlib.Path) -> None:
196 repo, cid = _populated_repo(tmp_path)
197 result = _rev(repo, "--json", "main")
198 assert result.exit_code == 0
199 data = json.loads(result.output)
200 assert data["commit_id"] == cid
201
202 def test_unknown_branch_not_found(self, tmp_path: pathlib.Path) -> None:
203 repo = _make_repo(tmp_path)
204 result = _rev(repo, "nonexistent-branch")
205 assert result.exit_code == ExitCode.USER_ERROR
206 data = json.loads(result.output)
207 assert data["commit_id"] is None
208 assert data["error"] == "not found"
209
210
211 # ---------------------------------------------------------------------------
212 # Integration — HEAD resolution
213 # ---------------------------------------------------------------------------
214
215
216 class TestHeadResolution:
217 def test_resolve_head(self, tmp_path: pathlib.Path) -> None:
218 repo, cid = _populated_repo(tmp_path)
219 result = _rev(repo, "--json", "HEAD")
220 assert result.exit_code == 0
221 data = json.loads(result.output)
222 assert data["commit_id"] == cid
223
224 def test_head_lowercase_also_resolves(self, tmp_path: pathlib.Path) -> None:
225 """HEAD resolution is case-insensitive (matches git behaviour)."""
226 repo, cid = _populated_repo(tmp_path)
227 result = _rev(repo, "--json", "head")
228 assert result.exit_code == 0
229 data = json.loads(result.output)
230 assert data["commit_id"] == cid
231
232 def test_head_on_empty_repo_errors(self, tmp_path: pathlib.Path) -> None:
233 """HEAD on a repo with no commits should error cleanly."""
234 repo = _make_repo(tmp_path)
235 result = _rev(repo, "HEAD")
236 assert result.exit_code == ExitCode.USER_ERROR
237 data = json.loads(result.output)
238 assert data["commit_id"] is None
239 assert "no commits" in data["error"]
240
241
242 # ---------------------------------------------------------------------------
243 # Integration — SHA prefix resolution
244 # ---------------------------------------------------------------------------
245
246
247 class TestShaResolution:
248 def test_resolve_full_sha(self, tmp_path: pathlib.Path) -> None:
249 repo, cid = _populated_repo(tmp_path)
250 result = _rev(repo, "--json", cid)
251 assert result.exit_code == 0
252 data = json.loads(result.output)
253 assert data["commit_id"] == cid
254
255 def test_resolve_8char_prefix(self, tmp_path: pathlib.Path) -> None:
256 repo, cid = _populated_repo(tmp_path)
257 prefix = "sha256:" + cid[7:15] # sha256: + first 8 hex chars
258 result = _rev(repo, "--json", prefix)
259 assert result.exit_code == 0
260 data = json.loads(result.output)
261 assert data["commit_id"] == cid
262
263 def test_ambiguous_prefix_returns_candidates(self, tmp_path: pathlib.Path) -> None:
264 """Two commits sharing a prefix → error with candidates list."""
265 # Messages "commit-search-165" and "commit-search-106" produce IDs
266 # sharing the 4-char hex prefix "9f7c" (same snapshot, same timestamp).
267 _AMBIG_MSG_1 = "commit-search-165"
268 _AMBIG_MSG_2 = "commit-search-106"
269 _AMBIG_PREFIX = "9f7c"
270
271 repo = _make_repo(tmp_path)
272 sid = _store_snap(repo)
273 cid1 = _make_commit(repo, sid, branch="main", message=_AMBIG_MSG_1)
274 cid2 = _make_commit(repo, sid, branch="dev", message=_AMBIG_MSG_2)
275 # cid1/cid2 are sha256:<hex>; compare the hex portion only
276 assert cid1[7:11] == cid2[7:11] == _AMBIG_PREFIX
277 _set_head(repo, "main", cid1)
278 _set_head(repo, "dev", cid2)
279
280 result = _rev(repo, "sha256:" + _AMBIG_PREFIX)
281 assert result.exit_code == ExitCode.USER_ERROR
282 data = json.loads(result.output)
283 assert data["error"] == "ambiguous"
284 assert set(data["candidates"]) == {cid1, cid2}
285
286 def test_nonexistent_full_sha_not_found(self, tmp_path: pathlib.Path) -> None:
287 repo = _make_repo(tmp_path)
288 result = _rev(repo, "sha256:" + "f" * 64)
289 assert result.exit_code == ExitCode.USER_ERROR
290 data = json.loads(result.output)
291 assert data["error"] == "not found"
292
293
294 # ---------------------------------------------------------------------------
295 # Integration — --abbrev-ref
296 # ---------------------------------------------------------------------------
297
298
299 class TestAbbrevRef:
300 def test_abbrev_ref_head_returns_branch_name(self, tmp_path: pathlib.Path) -> None:
301 """The canonical agent UX: what branch am I on?"""
302 repo = _make_repo(tmp_path, branch="feat/my-feature")
303 result = _rev(repo, "--abbrev-ref", "--json", "HEAD")
304 assert result.exit_code == 0
305 data = json.loads(result.output)
306 assert data["branch"] == "feat/my-feature"
307 assert data["ref"] == "HEAD"
308
309 def test_abbrev_ref_text_format(self, tmp_path: pathlib.Path) -> None:
310 repo = _make_repo(tmp_path, branch="dev")
311 result = _rev(repo, "--abbrev-ref", "--format", "text", "HEAD")
312 assert result.exit_code == 0
313 assert result.output.strip() == "dev"
314
315 def test_abbrev_ref_main(self, tmp_path: pathlib.Path) -> None:
316 repo = _make_repo(tmp_path, branch="main")
317 result = _rev(repo, "--abbrev-ref", "--json", "HEAD")
318 assert result.exit_code == 0
319 assert json.loads(result.output)["branch"] == "main"
320
321
322 # ---------------------------------------------------------------------------
323 # Edge cases
324 # ---------------------------------------------------------------------------
325
326
327 class TestEdgeCases:
328 def test_empty_ref_clean_error(self, tmp_path: pathlib.Path) -> None:
329 """Empty string ref must give a clear 'ref must not be empty' error."""
330 repo = _make_repo(tmp_path)
331 result = _rev(repo, "")
332 assert result.exit_code == ExitCode.USER_ERROR
333 data = json.loads(result.output)
334 assert "empty" in data["error"]
335
336 def test_invalid_format_errors(self, tmp_path: pathlib.Path) -> None:
337 repo, _ = _populated_repo(tmp_path)
338 result = _rev(repo, "--format", "xml", "main")
339 assert result.exit_code == ExitCode.USER_ERROR
340
341 def test_branch_with_slash_resolves(self, tmp_path: pathlib.Path) -> None:
342 repo = _make_repo(tmp_path, branch="feat/my-feature")
343 sid = _store_snap(repo)
344 cid = _make_commit(repo, sid, branch="feat/my-feature", message="feat-init")
345 _set_head(repo, "feat/my-feature", cid)
346 result = _rev(repo, "--json", "feat/my-feature")
347 assert result.exit_code == 0
348 assert json.loads(result.output)["commit_id"] == cid
349
350
351 # ---------------------------------------------------------------------------
352 # Security
353 # ---------------------------------------------------------------------------
354
355
356 class TestSecurity:
357 def test_ansi_in_ref_is_json_escaped(self, tmp_path: pathlib.Path) -> None:
358 """ANSI escape in ref is safely JSON-encoded, never echoed raw."""
359 repo = _make_repo(tmp_path)
360 evil = "\x1b[31mevil\x1b[0m"
361 result = _rev(repo, evil)
362 assert result.exit_code == ExitCode.USER_ERROR
363 # Output is JSON — ANSI must be encoded as \u001b, not emitted raw
364 assert "\x1b" not in result.output
365 data = json.loads(result.output)
366 assert data["error"] == "not found"
367
368 def test_path_traversal_ref_gives_not_found(self, tmp_path: pathlib.Path) -> None:
369 repo = _make_repo(tmp_path)
370 result = _rev(repo, "../../../etc/passwd")
371 assert result.exit_code == ExitCode.USER_ERROR
372
373 def test_null_byte_in_ref(self, tmp_path: pathlib.Path) -> None:
374 repo = _make_repo(tmp_path)
375 result = _rev(repo, "branch\x00null")
376 assert result.exit_code == ExitCode.USER_ERROR
377
378 def test_no_traceback_on_bad_input(self, tmp_path: pathlib.Path) -> None:
379 repo = _make_repo(tmp_path)
380 result = _rev(repo, "")
381 assert "Traceback" not in result.output
382
383
384 # ---------------------------------------------------------------------------
385 # Stress
386 # ---------------------------------------------------------------------------
387
388
389 class TestStress:
390 def test_200_rapid_resolves(self, tmp_path: pathlib.Path) -> None:
391 repo, cid = _populated_repo(tmp_path)
392 for i in range(200):
393 result = _rev(repo, "--json", "main")
394 assert result.exit_code == 0, f"failed at iteration {i}"
395 assert json.loads(result.output)["commit_id"] == cid
396
397 def test_200_abbrev_ref_resolves(self, tmp_path: pathlib.Path) -> None:
398 repo = _make_repo(tmp_path, branch="dev")
399 for i in range(200):
400 result = _rev(repo, "--abbrev-ref", "--json", "HEAD")
401 assert result.exit_code == 0, f"failed at iteration {i}"
402 assert json.loads(result.output)["branch"] == "dev"
File History 1 commit
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 150 days ago