gabriel / muse public
test_cmd_rev_parse.py python
503 lines 19.9 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 141 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, long_id
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_json(self, tmp_path: pathlib.Path) -> None:
113 """Without --format the output defaults to JSON."""
114 repo, cid = _populated_repo(tmp_path)
115 result = _rev(repo, "main")
116 assert result.exit_code == 0
117 data = json.loads(result.output)
118 assert data["commit_id"] == cid
119 assert data["ref"] == "main"
120
121 def test_text_format_gives_plain_commit_id(self, tmp_path: pathlib.Path) -> None:
122 """With --format text the output is just the bare commit ID."""
123 repo, cid = _populated_repo(tmp_path)
124 result = _rev(repo, "--format", "text", "main")
125 assert result.exit_code == 0
126 assert result.output.strip() == cid
127 with pytest.raises((json.JSONDecodeError, ValueError)):
128 json.loads(result.output)
129
130 def test_json_flag_gives_dict_output(self, tmp_path: pathlib.Path) -> None:
131 """With --json output is a dict."""
132 repo, cid = _populated_repo(tmp_path)
133 result = _rev(repo, "--json", "main")
134 assert result.exit_code == 0
135 data = json.loads(result.output)
136 assert data["commit_id"] == cid
137 assert data["ref"] == "main"
138
139 def test_format_text_explicit(self, tmp_path: pathlib.Path) -> None:
140 repo, cid = _populated_repo(tmp_path)
141 result = _rev(repo, "--format", "text", "main")
142 assert result.exit_code == 0
143 assert result.output.strip() == cid
144
145
146 # ---------------------------------------------------------------------------
147 # New: sha256: prefix is required; bare hex is rejected
148 # ---------------------------------------------------------------------------
149
150
151 class TestSha256PrefixRequired:
152 def test_bare_full_hex_rejected(self, tmp_path: pathlib.Path) -> None:
153 """64-char bare hex without sha256: prefix must be rejected."""
154 repo, cid = _populated_repo(tmp_path)
155 result = _rev(repo, cid[len("sha256:"):])
156 assert result.exit_code == ExitCode.USER_ERROR
157 data = json.loads(result.output)
158 assert "sha256:" in data["error"]
159
160 def test_bare_short_hex_rejected(self, tmp_path: pathlib.Path) -> None:
161 """Short bare hex without sha256: prefix must be rejected."""
162 repo, cid = _populated_repo(tmp_path)
163 result = _rev(repo, cid[7:15]) # 8 bare hex chars
164 assert result.exit_code == ExitCode.USER_ERROR
165 data = json.loads(result.output)
166 assert "sha256:" in data["error"]
167
168 def test_canonical_full_id_resolves(self, tmp_path: pathlib.Path) -> None:
169 """sha256:<64hex> must resolve to the commit."""
170 repo, cid = _populated_repo(tmp_path)
171 result = _rev(repo, "--json", cid)
172 assert result.exit_code == 0
173 assert json.loads(result.output)["commit_id"] == cid
174
175 def test_canonical_prefix_resolves(self, tmp_path: pathlib.Path) -> None:
176 """sha256:<8hex> prefix must resolve to the commit."""
177 repo, cid = _populated_repo(tmp_path)
178 prefix = long_id(cid[7:15])# sha256: + 8 hex chars
179 result = _rev(repo, "--json", prefix)
180 assert result.exit_code == 0
181 assert json.loads(result.output)["commit_id"] == cid
182
183
184 # ---------------------------------------------------------------------------
185 # Integration — branch resolution
186 # ---------------------------------------------------------------------------
187
188
189 class TestBranchResolution:
190 def test_resolve_branch_json(self, tmp_path: pathlib.Path) -> None:
191 repo, cid = _populated_repo(tmp_path)
192 result = _rev(repo, "--json", "main")
193 assert result.exit_code == 0
194 data = json.loads(result.output)
195 assert data["commit_id"] == cid
196 assert data["ref"] == "main"
197
198 def test_resolve_branch_text(self, tmp_path: pathlib.Path) -> None:
199 repo, cid = _populated_repo(tmp_path)
200 result = _rev(repo, "--format", "text", "main")
201 assert result.exit_code == 0
202 assert result.output.strip() == cid
203
204 def test_json_flag_shorthand(self, tmp_path: pathlib.Path) -> None:
205 repo, cid = _populated_repo(tmp_path)
206 result = _rev(repo, "--json", "main")
207 assert result.exit_code == 0
208 data = json.loads(result.output)
209 assert data["commit_id"] == cid
210
211 def test_unknown_branch_not_found(self, tmp_path: pathlib.Path) -> None:
212 repo = _make_repo(tmp_path)
213 result = _rev(repo, "nonexistent-branch")
214 assert result.exit_code == ExitCode.USER_ERROR
215 data = json.loads(result.output)
216 assert data["commit_id"] is None
217 assert data["error"] == "not found"
218
219
220 # ---------------------------------------------------------------------------
221 # Integration — HEAD resolution
222 # ---------------------------------------------------------------------------
223
224
225 class TestHeadResolution:
226 def test_resolve_head(self, tmp_path: pathlib.Path) -> None:
227 repo, cid = _populated_repo(tmp_path)
228 result = _rev(repo, "--json", "HEAD")
229 assert result.exit_code == 0
230 data = json.loads(result.output)
231 assert data["commit_id"] == cid
232
233 def test_head_lowercase_also_resolves(self, tmp_path: pathlib.Path) -> None:
234 """HEAD resolution is case-insensitive (matches git behaviour)."""
235 repo, cid = _populated_repo(tmp_path)
236 result = _rev(repo, "--json", "head")
237 assert result.exit_code == 0
238 data = json.loads(result.output)
239 assert data["commit_id"] == cid
240
241 def test_head_on_empty_repo_errors(self, tmp_path: pathlib.Path) -> None:
242 """HEAD on a repo with no commits should error cleanly."""
243 repo = _make_repo(tmp_path)
244 result = _rev(repo, "HEAD")
245 assert result.exit_code == ExitCode.USER_ERROR
246 data = json.loads(result.output)
247 assert data["commit_id"] is None
248 assert "no commits" in data["error"]
249
250
251 # ---------------------------------------------------------------------------
252 # Integration — SHA prefix resolution
253 # ---------------------------------------------------------------------------
254
255
256 class TestShaResolution:
257 def test_resolve_full_sha(self, tmp_path: pathlib.Path) -> None:
258 repo, cid = _populated_repo(tmp_path)
259 result = _rev(repo, "--json", cid)
260 assert result.exit_code == 0
261 data = json.loads(result.output)
262 assert data["commit_id"] == cid
263
264 def test_resolve_8char_prefix(self, tmp_path: pathlib.Path) -> None:
265 repo, cid = _populated_repo(tmp_path)
266 prefix = long_id(cid[7:15])# sha256: + first 8 hex chars
267 result = _rev(repo, "--json", prefix)
268 assert result.exit_code == 0
269 data = json.loads(result.output)
270 assert data["commit_id"] == cid
271
272 def test_ambiguous_prefix_returns_candidates(self, tmp_path: pathlib.Path) -> None:
273 """Two commits sharing a prefix → error with candidates list."""
274 # Messages "commit-search-165" and "commit-search-106" produce IDs
275 # sharing the 4-char hex prefix "9f7c" (same snapshot, same timestamp).
276 _AMBIG_MSG_1 = "commit-search-165"
277 _AMBIG_MSG_2 = "commit-search-106"
278 _AMBIG_PREFIX = "9f7c"
279
280 repo = _make_repo(tmp_path)
281 sid = _store_snap(repo)
282 cid1 = _make_commit(repo, sid, branch="main", message=_AMBIG_MSG_1)
283 cid2 = _make_commit(repo, sid, branch="dev", message=_AMBIG_MSG_2)
284 # cid1/cid2 are sha256:<hex>; compare the hex portion only
285 assert cid1[7:11] == cid2[7:11] == _AMBIG_PREFIX
286 _set_head(repo, "main", cid1)
287 _set_head(repo, "dev", cid2)
288
289 result = _rev(repo, long_id(_AMBIG_PREFIX))
290 assert result.exit_code == ExitCode.USER_ERROR
291 data = json.loads(result.output)
292 assert data["error"] == "ambiguous"
293 assert set(data["candidates"]) == {cid1, cid2}
294
295 def test_nonexistent_full_sha_not_found(self, tmp_path: pathlib.Path) -> None:
296 repo = _make_repo(tmp_path)
297 result = _rev(repo, long_id("f" * 64))
298 assert result.exit_code == ExitCode.USER_ERROR
299 data = json.loads(result.output)
300 assert data["error"] == "not found"
301
302
303 # ---------------------------------------------------------------------------
304 # Integration — --abbrev-ref
305 # ---------------------------------------------------------------------------
306
307
308 class TestAbbrevRef:
309 def test_abbrev_ref_head_returns_branch_name(self, tmp_path: pathlib.Path) -> None:
310 """The canonical agent UX: what branch am I on?"""
311 repo = _make_repo(tmp_path, branch="feat/my-feature")
312 result = _rev(repo, "--abbrev-ref", "--json", "HEAD")
313 assert result.exit_code == 0
314 data = json.loads(result.output)
315 assert data["branch"] == "feat/my-feature"
316 assert data["ref"] == "HEAD"
317
318 def test_abbrev_ref_text_format(self, tmp_path: pathlib.Path) -> None:
319 repo = _make_repo(tmp_path, branch="dev")
320 result = _rev(repo, "--abbrev-ref", "--format", "text", "HEAD")
321 assert result.exit_code == 0
322 assert result.output.strip() == "dev"
323
324 def test_abbrev_ref_main(self, tmp_path: pathlib.Path) -> None:
325 repo = _make_repo(tmp_path, branch="main")
326 result = _rev(repo, "--abbrev-ref", "--json", "HEAD")
327 assert result.exit_code == 0
328 assert json.loads(result.output)["branch"] == "main"
329
330
331 # ---------------------------------------------------------------------------
332 # Edge cases
333 # ---------------------------------------------------------------------------
334
335
336 class TestEdgeCases:
337 def test_empty_ref_clean_error(self, tmp_path: pathlib.Path) -> None:
338 """Empty string ref must give a clear 'ref must not be empty' error."""
339 repo = _make_repo(tmp_path)
340 result = _rev(repo, "")
341 assert result.exit_code == ExitCode.USER_ERROR
342 data = json.loads(result.output)
343 assert "empty" in data["error"]
344
345 def test_invalid_format_errors(self, tmp_path: pathlib.Path) -> None:
346 repo, _ = _populated_repo(tmp_path)
347 result = _rev(repo, "--format", "xml", "main")
348 assert result.exit_code == ExitCode.USER_ERROR
349
350 def test_branch_with_slash_resolves(self, tmp_path: pathlib.Path) -> None:
351 repo = _make_repo(tmp_path, branch="feat/my-feature")
352 sid = _store_snap(repo)
353 cid = _make_commit(repo, sid, branch="feat/my-feature", message="feat-init")
354 _set_head(repo, "feat/my-feature", cid)
355 result = _rev(repo, "--json", "feat/my-feature")
356 assert result.exit_code == 0
357 assert json.loads(result.output)["commit_id"] == cid
358
359
360 # ---------------------------------------------------------------------------
361 # Security
362 # ---------------------------------------------------------------------------
363
364
365 class TestSecurity:
366 def test_ansi_in_ref_is_json_escaped(self, tmp_path: pathlib.Path) -> None:
367 """ANSI escape in ref is safely JSON-encoded, never echoed raw."""
368 repo = _make_repo(tmp_path)
369 evil = "\x1b[31mevil\x1b[0m"
370 result = _rev(repo, evil)
371 assert result.exit_code == ExitCode.USER_ERROR
372 # Output is JSON — ANSI must be encoded as \u001b, not emitted raw
373 assert "\x1b" not in result.output
374 data = json.loads(result.output)
375 assert data["error"] == "not found"
376
377 def test_path_traversal_ref_gives_not_found(self, tmp_path: pathlib.Path) -> None:
378 repo = _make_repo(tmp_path)
379 result = _rev(repo, "../../../etc/passwd")
380 assert result.exit_code == ExitCode.USER_ERROR
381
382 def test_null_byte_in_ref(self, tmp_path: pathlib.Path) -> None:
383 repo = _make_repo(tmp_path)
384 result = _rev(repo, "branch\x00null")
385 assert result.exit_code == ExitCode.USER_ERROR
386
387 def test_no_traceback_on_bad_input(self, tmp_path: pathlib.Path) -> None:
388 repo = _make_repo(tmp_path)
389 result = _rev(repo, "")
390 assert "Traceback" not in result.output
391
392
393 # ---------------------------------------------------------------------------
394 # JSON schema — duration_ms + exit_code on every output path
395 # ---------------------------------------------------------------------------
396
397
398 class TestJsonSchema:
399 """Every JSON response must carry duration_ms (float ≥ 0) and exit_code (int)."""
400
401 def _assert_schema(self, d: dict, expected_exit: int = 0) -> None:
402 assert "duration_ms" in d, f"duration_ms missing: {d}"
403 assert isinstance(d["duration_ms"], (int, float))
404 assert d["duration_ms"] >= 0
405 assert "exit_code" in d, f"exit_code missing: {d}"
406 assert d["exit_code"] == expected_exit
407
408 def test_branch_resolution_has_schema(self, tmp_path: pathlib.Path) -> None:
409 repo, cid = _populated_repo(tmp_path)
410 result = _rev(repo, "--json", "main")
411 self._assert_schema(json.loads(result.output))
412
413 def test_head_resolution_has_schema(self, tmp_path: pathlib.Path) -> None:
414 repo, cid = _populated_repo(tmp_path)
415 result = _rev(repo, "--json", "HEAD")
416 self._assert_schema(json.loads(result.output))
417
418 def test_sha_resolution_has_schema(self, tmp_path: pathlib.Path) -> None:
419 repo, cid = _populated_repo(tmp_path)
420 result = _rev(repo, "--json", cid)
421 self._assert_schema(json.loads(result.output))
422
423 def test_abbrev_ref_has_schema(self, tmp_path: pathlib.Path) -> None:
424 repo = _make_repo(tmp_path, branch="feat/x")
425 result = _rev(repo, "--abbrev-ref", "--json", "HEAD")
426 self._assert_schema(json.loads(result.output))
427
428 def test_prefix_resolution_has_schema(self, tmp_path: pathlib.Path) -> None:
429 repo, cid = _populated_repo(tmp_path)
430 prefix = long_id(cid[7:15])
431 result = _rev(repo, "--json", prefix)
432 self._assert_schema(json.loads(result.output))
433
434
435 # ---------------------------------------------------------------------------
436 # Error JSON — all error paths emit structured JSON to stdout
437 # ---------------------------------------------------------------------------
438
439
440 class TestErrorJson:
441 """Every error must emit a parseable JSON dict to stdout (not stderr)."""
442
443 def _assert_error(self, result: InvokeResult) -> dict:
444 assert result.exit_code != 0, "expected non-zero exit"
445 d = json.loads(result.output) # stdout, not stderr
446 assert "error" in d
447 assert "duration_ms" in d, f"duration_ms missing from error: {d}"
448 assert "exit_code" in d
449 assert d["exit_code"] != 0
450 return d
451
452 def test_empty_ref_emits_json_to_stdout(self, tmp_path: pathlib.Path) -> None:
453 """Empty ref error must land on stdout as JSON, not stderr."""
454 repo = _make_repo(tmp_path)
455 result = _rev(repo, "")
456 self._assert_error(result)
457 assert "empty" in json.loads(result.output)["error"]
458
459 def test_invalid_format_emits_json_to_stdout(self, tmp_path: pathlib.Path) -> None:
460 """Unknown --format must emit a JSON error to stdout, not plain text to stderr."""
461 repo, _ = _populated_repo(tmp_path)
462 result = _rev(repo, "--format", "xml", "main")
463 self._assert_error(result)
464
465 def test_not_found_has_schema(self, tmp_path: pathlib.Path) -> None:
466 repo = _make_repo(tmp_path)
467 result = _rev(repo, "nonexistent-branch")
468 self._assert_error(result)
469 assert json.loads(result.output)["error"] == "not found"
470
471 def test_ambiguous_prefix_has_schema(self, tmp_path: pathlib.Path) -> None:
472 _AMBIG_MSG_1 = "commit-search-165"
473 _AMBIG_MSG_2 = "commit-search-106"
474 _AMBIG_PREFIX = "9f7c"
475 repo = _make_repo(tmp_path)
476 sid = _store_snap(repo)
477 cid1 = _make_commit(repo, sid, branch="main", message=_AMBIG_MSG_1)
478 cid2 = _make_commit(repo, sid, branch="dev", message=_AMBIG_MSG_2)
479 assert cid1[7:11] == cid2[7:11] == _AMBIG_PREFIX
480 _set_head(repo, "main", cid1)
481 _set_head(repo, "dev", cid2)
482 result = _rev(repo, long_id(_AMBIG_PREFIX))
483 d = self._assert_error(result)
484 assert d["error"] == "ambiguous"
485
486 def test_head_no_commits_has_schema(self, tmp_path: pathlib.Path) -> None:
487 repo = _make_repo(tmp_path)
488 result = _rev(repo, "HEAD")
489 self._assert_error(result)
490 assert "no commits" in json.loads(result.output)["error"]
491
492 def test_bare_hex_has_schema(self, tmp_path: pathlib.Path) -> None:
493 repo, cid = _populated_repo(tmp_path)
494 result = _rev(repo, cid[len("sha256:"):])
495 d = self._assert_error(result)
496 assert "sha256:" in d["error"]
497
498 def test_error_json_has_ref_key(self, tmp_path: pathlib.Path) -> None:
499 """Every error dict must echo back the ref the caller passed."""
500 repo = _make_repo(tmp_path)
501 result = _rev(repo, "missing-branch")
502 d = json.loads(result.output)
503 assert d["ref"] == "missing-branch"
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 141 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 144 days ago