gabriel / muse public
test_cmd_show_ref.py python
471 lines 18.5 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
1 """Comprehensive tests for ``muse show-ref``.
2
3 Audit findings addressed here
4 ------------------------------
5 Security
6 - Format error now goes to stderr (was stdout) — verified below.
7 - ANSI injection in branch names and commit IDs stripped in text mode.
8 - Symlink refs in .muse/refs/heads/ are silently skipped.
9 - Ref files with non-hex content are silently skipped.
10
11 Agent UX
12 - ``--verify`` now emits JSON when combined with ``--json`` (was silent).
13 - ``--count`` added for branch inventory without reading all commit IDs.
14 - ``--pattern`` default changed from ``""`` to ``None`` (cleaner guard).
15
16 Performance
17 - Symlink check and commit-ID validation happen in ``_list_branch_refs``
18 before the output path, so corrupt refs never surface to callers.
19
20 Coverage tiers
21 --------------
22 - Unit: _list_branch_refs, _head_info, _ShowRefResult schema
23 - Integration: JSON/text output, --head, --verify (json + exit), --count,
24 --pattern, empty repo, no HEAD commit, multi-branch sorting
25 - Security: ANSI stripped in text mode, symlinks skipped, invalid commit IDs
26 skipped, format error to stderr, no traceback on errors
27 - Stress: 200 sequential full-list calls, 100-branch repo listing
28 """
29 from __future__ import annotations
30
31 import json
32 import os
33 import pathlib
34
35 import pytest
36
37 from muse.core._types import long_id
38 from muse.core.errors import ExitCode
39 from tests.cli_test_helper import CliRunner, InvokeResult
40
41 runner = CliRunner()
42
43 # ---------------------------------------------------------------------------
44 # Helpers
45 # ---------------------------------------------------------------------------
46
47 _FAKE_OID = long_id("a" * 64)
48
49
50 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
51 repo = tmp_path / "repo"
52 muse = repo / ".muse"
53 (muse / "objects").mkdir(parents=True)
54 (muse / "commits").mkdir(parents=True)
55 (muse / "snapshots").mkdir(parents=True)
56 (muse / "refs" / "heads").mkdir(parents=True)
57 (muse / "HEAD").write_text("ref: refs/heads/main")
58 (muse / "repo.json").write_text(json.dumps({"repo_id": "r1", "domain": "code"}))
59 return repo
60
61
62 def _write_ref(repo: pathlib.Path, branch: str, commit_id: str = _FAKE_OID) -> None:
63 ref_path = repo / ".muse" / "refs" / "heads" / branch
64 ref_path.write_text(commit_id)
65
66
67 def _sr(repo: pathlib.Path, *args: str) -> InvokeResult:
68 from muse.cli.app import main as cli
69 return runner.invoke(cli, ["show-ref", *args],
70 env={"MUSE_REPO_ROOT": str(repo)})
71
72
73 # ---------------------------------------------------------------------------
74 # Unit — private helpers
75 # ---------------------------------------------------------------------------
76
77
78 class TestListBranchRefs:
79 def test_empty_heads_dir(self, tmp_path: pathlib.Path) -> None:
80 from muse.cli.commands.show_ref import _list_branch_refs
81 repo = _make_repo(tmp_path)
82 assert _list_branch_refs(repo) == []
83
84 def test_single_ref(self, tmp_path: pathlib.Path) -> None:
85 from muse.cli.commands.show_ref import _list_branch_refs
86 repo = _make_repo(tmp_path)
87 _write_ref(repo, "main")
88 refs = _list_branch_refs(repo)
89 assert len(refs) == 1
90 assert refs[0]["ref"] == "refs/heads/main"
91 assert refs[0]["commit_id"] == _FAKE_OID
92
93 def test_multiple_refs_sorted(self, tmp_path: pathlib.Path) -> None:
94 from muse.cli.commands.show_ref import _list_branch_refs
95 repo = _make_repo(tmp_path)
96 _write_ref(repo, "zeta", long_id("b" * 64))
97 _write_ref(repo, "alpha", long_id("c" * 64))
98 _write_ref(repo, "main", long_id("d" * 64))
99 refs = _list_branch_refs(repo)
100 names = [r["ref"] for r in refs]
101 assert names == ["refs/heads/alpha", "refs/heads/main", "refs/heads/zeta"]
102
103 def test_invalid_commit_id_skipped(self, tmp_path: pathlib.Path) -> None:
104 from muse.cli.commands.show_ref import _list_branch_refs
105 repo = _make_repo(tmp_path)
106 _write_ref(repo, "good", long_id("a" * 64))
107 _write_ref(repo, "bad", "not-a-sha256")
108 refs = _list_branch_refs(repo)
109 assert len(refs) == 1
110 assert refs[0]["ref"] == "refs/heads/good"
111
112 def test_empty_ref_file_skipped(self, tmp_path: pathlib.Path) -> None:
113 from muse.cli.commands.show_ref import _list_branch_refs
114 repo = _make_repo(tmp_path)
115 _write_ref(repo, "empty", "")
116 assert _list_branch_refs(repo) == []
117
118 def test_symlink_ref_skipped(self, tmp_path: pathlib.Path) -> None:
119 from muse.cli.commands.show_ref import _list_branch_refs
120 repo = _make_repo(tmp_path)
121 _write_ref(repo, "real", long_id("a" * 64))
122 sym = repo / ".muse" / "refs" / "heads" / "sym-branch"
123 sym.symlink_to(repo / ".muse" / "refs" / "heads" / "real")
124 refs = _list_branch_refs(repo)
125 # Only the real branch should appear; the symlink is skipped.
126 assert len(refs) == 1
127 assert refs[0]["ref"] == "refs/heads/real"
128
129 def test_nonexistent_heads_dir(self, tmp_path: pathlib.Path) -> None:
130 from muse.cli.commands.show_ref import _list_branch_refs
131 repo = _make_repo(tmp_path)
132 import shutil
133 shutil.rmtree(repo / ".muse" / "refs" / "heads")
134 assert _list_branch_refs(repo) == []
135
136
137 class TestHeadInfo:
138 def test_returns_none_on_empty_branch(self, tmp_path: pathlib.Path) -> None:
139 from muse.cli.commands.show_ref import _head_info
140 repo = _make_repo(tmp_path)
141 # HEAD points to main but no ref file written → commit_id is None
142 assert _head_info(repo) is None
143
144 def test_returns_info_when_commit_present(self, tmp_path: pathlib.Path) -> None:
145 from muse.cli.commands.show_ref import _head_info
146 repo = _make_repo(tmp_path)
147 _write_ref(repo, "main")
148 info = _head_info(repo)
149 assert info is not None
150 assert info["branch"] == "main"
151 assert info["commit_id"] == _FAKE_OID
152 assert info["ref"] == "refs/heads/main"
153
154 def test_schema_fields_present(self, tmp_path: pathlib.Path) -> None:
155 from muse.cli.commands.show_ref import _HeadInfo
156 fields = set(_HeadInfo.__annotations__)
157 assert fields == {"ref", "branch", "commit_id"}
158
159
160 class TestShowRefResultSchema:
161 def test_schema_fields(self) -> None:
162 from muse.cli.commands.show_ref import _ShowRefResult
163 fields = set(_ShowRefResult.__annotations__)
164 assert "refs" in fields
165 assert "head" in fields
166 assert "count" in fields
167 assert "duration_ms" in fields
168 assert "exit_code" in fields
169
170
171 # ---------------------------------------------------------------------------
172 # Integration — JSON output
173 # ---------------------------------------------------------------------------
174
175
176 class TestJsonOutput:
177 def test_empty_repo_zero_refs(self, tmp_path: pathlib.Path) -> None:
178 repo = _make_repo(tmp_path)
179 result = _sr(repo, "--json")
180 assert result.exit_code == 0
181 data = json.loads(result.output)
182 assert data["refs"] == []
183 assert data["count"] == 0
184
185 def test_single_branch_present(self, tmp_path: pathlib.Path) -> None:
186 repo = _make_repo(tmp_path)
187 _write_ref(repo, "main")
188 data = json.loads(_sr(repo, "--json").output)
189 assert data["count"] == 1
190 assert data["refs"][0]["ref"] == "refs/heads/main"
191 assert data["refs"][0]["commit_id"] == _FAKE_OID
192
193 def test_head_present_when_commit_exists(self, tmp_path: pathlib.Path) -> None:
194 repo = _make_repo(tmp_path)
195 _write_ref(repo, "main")
196 data = json.loads(_sr(repo, "--json").output)
197 assert data["head"] is not None
198 assert data["head"]["branch"] == "main"
199
200 def test_head_null_when_no_commit(self, tmp_path: pathlib.Path) -> None:
201 repo = _make_repo(tmp_path)
202 data = json.loads(_sr(repo, "--json").output)
203 assert data["head"] is None
204
205 def test_json_shorthand_flag(self, tmp_path: pathlib.Path) -> None:
206 repo = _make_repo(tmp_path)
207 result = _sr(repo, "--json")
208 assert result.exit_code == 0
209 assert "refs" in json.loads(result.output)
210
211 def test_multi_branch_sorted(self, tmp_path: pathlib.Path) -> None:
212 repo = _make_repo(tmp_path)
213 _write_ref(repo, "zeta", "b" * 64)
214 _write_ref(repo, "alpha", "c" * 64)
215 data = json.loads(_sr(repo, "--json").output)
216 refs = [r["ref"] for r in data["refs"]]
217 assert refs == sorted(refs)
218
219
220 # ---------------------------------------------------------------------------
221 # Integration — text output
222 # ---------------------------------------------------------------------------
223
224
225 class TestTextOutput:
226 def test_commit_id_in_output(self, tmp_path: pathlib.Path) -> None:
227 repo = _make_repo(tmp_path)
228 _write_ref(repo, "main")
229 result = _sr(repo)
230 assert result.exit_code == 0
231 assert _FAKE_OID in result.output
232
233 def test_head_marker_present(self, tmp_path: pathlib.Path) -> None:
234 repo = _make_repo(tmp_path)
235 _write_ref(repo, "main")
236 result = _sr(repo)
237 assert "* " in result.output
238 assert "(HEAD)" in result.output
239
240 def test_empty_repo_no_output(self, tmp_path: pathlib.Path) -> None:
241 repo = _make_repo(tmp_path)
242 result = _sr(repo)
243 assert result.exit_code == 0
244 assert result.output.strip() == ""
245
246
247 # ---------------------------------------------------------------------------
248 # Integration — --head mode
249 # ---------------------------------------------------------------------------
250
251
252 class TestHeadMode:
253 def test_json_head_present(self, tmp_path: pathlib.Path) -> None:
254 repo = _make_repo(tmp_path)
255 _write_ref(repo, "main")
256 data = json.loads(_sr(repo, "--head", "--json").output)
257 assert data["head"]["branch"] == "main"
258
259 def test_json_head_null(self, tmp_path: pathlib.Path) -> None:
260 repo = _make_repo(tmp_path)
261 data = json.loads(_sr(repo, "--head", "--json").output)
262 assert data["head"] is None
263
264 def test_text_head_present(self, tmp_path: pathlib.Path) -> None:
265 repo = _make_repo(tmp_path)
266 _write_ref(repo, "main")
267 result = _sr(repo, "--head")
268 assert "(HEAD)" in result.output
269 assert _FAKE_OID in result.output
270
271 def test_text_no_head(self, tmp_path: pathlib.Path) -> None:
272 repo = _make_repo(tmp_path)
273 result = _sr(repo, "--head")
274 assert "no HEAD commit" in result.output
275
276
277 # ---------------------------------------------------------------------------
278 # Integration — --verify mode (agent UX supercharge)
279 # ---------------------------------------------------------------------------
280
281
282 class TestVerifyMode:
283 def test_existing_ref_exits_0(self, tmp_path: pathlib.Path) -> None:
284 repo = _make_repo(tmp_path)
285 _write_ref(repo, "main")
286 result = _sr(repo, "--verify", "refs/heads/main")
287 assert result.exit_code == 0
288
289 def test_missing_ref_exits_1(self, tmp_path: pathlib.Path) -> None:
290 repo = _make_repo(tmp_path)
291 result = _sr(repo, "--verify", "refs/heads/nonexistent")
292 assert result.exit_code == ExitCode.USER_ERROR
293
294 def test_json_verify_exists_true(self, tmp_path: pathlib.Path) -> None:
295 """JSON output is now emitted — critical agent UX improvement."""
296 repo = _make_repo(tmp_path)
297 _write_ref(repo, "main")
298 result = _sr(repo, "--verify", "refs/heads/main", "--json")
299 assert result.exit_code == 0
300 data = json.loads(result.output)
301 assert data["exists"] is True
302 assert data["ref"] == "refs/heads/main"
303
304 def test_json_verify_exists_false(self, tmp_path: pathlib.Path) -> None:
305 repo = _make_repo(tmp_path)
306 result = _sr(repo, "--verify", "refs/heads/ghost", "--json")
307 assert result.exit_code == ExitCode.USER_ERROR
308 data = json.loads(result.output)
309 assert data["exists"] is False
310 assert data["ref"] == "refs/heads/ghost"
311
312
313 # ---------------------------------------------------------------------------
314 # Integration — --count mode (new)
315 # ---------------------------------------------------------------------------
316
317
318 class TestCountMode:
319 def test_json_count_zero(self, tmp_path: pathlib.Path) -> None:
320 repo = _make_repo(tmp_path)
321 data = json.loads(_sr(repo, "--count", "--json").output)
322 assert data["count"] == 0
323
324 def test_json_count_with_branches(self, tmp_path: pathlib.Path) -> None:
325 repo = _make_repo(tmp_path)
326 _write_ref(repo, "main")
327 _write_ref(repo, "dev", long_id("b" * 64))
328 data = json.loads(_sr(repo, "--count", "--json").output)
329 assert data["count"] == 2
330
331 def test_text_count(self, tmp_path: pathlib.Path) -> None:
332 repo = _make_repo(tmp_path)
333 _write_ref(repo, "main")
334 result = _sr(repo, "--count")
335 assert result.exit_code == 0
336 assert result.output.strip() == "1"
337
338 def test_count_with_pattern(self, tmp_path: pathlib.Path) -> None:
339 """--count respects --pattern filter."""
340 repo = _make_repo(tmp_path)
341 _write_ref(repo, "main")
342 _write_ref(repo, "feat-x", long_id("b" * 64))
343 _write_ref(repo, "feat-y", long_id("c" * 64))
344 data = json.loads(_sr(repo, "--count", "--pattern", "refs/heads/feat*", "--json").output)
345 assert data["count"] == 2
346
347
348 # ---------------------------------------------------------------------------
349 # Integration — --pattern filter
350 # ---------------------------------------------------------------------------
351
352
353 class TestPatternFilter:
354 def test_pattern_matches(self, tmp_path: pathlib.Path) -> None:
355 repo = _make_repo(tmp_path)
356 _write_ref(repo, "feat-a", long_id("b" * 64))
357 _write_ref(repo, "feat-b", long_id("c" * 64))
358 _write_ref(repo, "main")
359 data = json.loads(_sr(repo, "--pattern", "refs/heads/feat*", "--json").output)
360 assert data["count"] == 2
361 for r in data["refs"]:
362 assert r["ref"].startswith("refs/heads/feat")
363
364 def test_pattern_no_match(self, tmp_path: pathlib.Path) -> None:
365 repo = _make_repo(tmp_path)
366 _write_ref(repo, "main")
367 data = json.loads(_sr(repo, "--pattern", "refs/heads/release/*", "--json").output)
368 assert data["count"] == 0
369 assert data["refs"] == []
370
371
372 # ---------------------------------------------------------------------------
373 # Security
374 # ---------------------------------------------------------------------------
375
376
377 class TestSecurity:
378 def test_ansi_in_branch_name_stripped_text(self, tmp_path: pathlib.Path) -> None:
379 """Branch name with ANSI escape in ref path is sanitized in text output."""
380 repo = _make_repo(tmp_path)
381 ansi_branch = "\x1b[31mevil\x1b[0m"
382 ref_path = repo / ".muse" / "refs" / "heads" / ansi_branch
383 ref_path.write_text(long_id("a" * 64))
384 result = _sr(repo)
385 assert "\x1b" not in result.output
386
387 def test_ansi_in_commit_id_stripped_text(self, tmp_path: pathlib.Path) -> None:
388 """Commit ID with ANSI in ref file content is sanitized.
389 (The ref is also skipped by validate_object_id — no ANSI ever reaches output.)
390 """
391 repo = _make_repo(tmp_path)
392 _write_ref(repo, "main", "\x1b[31m" + "a" * 60)
393 result = _sr(repo)
394 assert "\x1b" not in result.output
395
396 def test_format_error_to_stderr(self, tmp_path: pathlib.Path) -> None:
397 repo = _make_repo(tmp_path)
398 result = _sr(repo, "--format", "yaml")
399 assert result.exit_code != 0
400 assert "error" in result.stderr.lower()
401 assert result.stdout_bytes == b""
402
403 def test_no_traceback_on_bad_format(self, tmp_path: pathlib.Path) -> None:
404 repo = _make_repo(tmp_path)
405 result = _sr(repo, "--format", "xml")
406 assert "Traceback" not in result.output
407
408 def test_symlink_ref_not_included(self, tmp_path: pathlib.Path) -> None:
409 repo = _make_repo(tmp_path)
410 _write_ref(repo, "real", long_id("a" * 64))
411 sym = repo / ".muse" / "refs" / "heads" / "sym"
412 sym.symlink_to(repo / ".muse" / "refs" / "heads" / "real")
413 data = json.loads(_sr(repo, "--json").output)
414 ref_names = [r["ref"] for r in data["refs"]]
415 assert "refs/heads/sym" not in ref_names
416 assert "refs/heads/real" in ref_names
417
418 def test_invalid_commit_id_ref_not_included(self, tmp_path: pathlib.Path) -> None:
419 repo = _make_repo(tmp_path)
420 _write_ref(repo, "good", long_id("a" * 64))
421 _write_ref(repo, "corrupt", "not-a-sha256-at-all")
422 data = json.loads(_sr(repo, "--json").output)
423 ref_names = [r["ref"] for r in data["refs"]]
424 assert "refs/heads/good" in ref_names
425 assert "refs/heads/corrupt" not in ref_names
426
427 def test_path_traversal_in_pattern_safe(self, tmp_path: pathlib.Path) -> None:
428 """A crafted pattern cannot escape the ref listing via fnmatch."""
429 repo = _make_repo(tmp_path)
430 _write_ref(repo, "main")
431 result = _sr(repo, "--pattern", "../../../../etc/*", "--json")
432 assert result.exit_code == 0
433 data = json.loads(result.output)
434 assert data["count"] == 0
435
436
437 # ---------------------------------------------------------------------------
438 # Stress
439 # ---------------------------------------------------------------------------
440
441
442 class TestStress:
443 def test_200_sequential_calls(self, tmp_path: pathlib.Path) -> None:
444 repo = _make_repo(tmp_path)
445 _write_ref(repo, "main")
446 for i in range(200):
447 result = _sr(repo, "--json")
448 assert result.exit_code == 0, f"failed at iteration {i}"
449 assert json.loads(result.output)["count"] == 1
450
451 def test_100_branch_repo(self, tmp_path: pathlib.Path) -> None:
452 """Listing 100 branches must complete and return the correct count."""
453 repo = _make_repo(tmp_path)
454 hex_chars = "0123456789abcdef"
455 for i in range(100):
456 # Build a deterministic valid sha256:-prefixed commit ID.
457 oid = long_id((hex_chars[i % 16]) * 64)
458 _write_ref(repo, f"branch-{i:04d}", oid)
459 data = json.loads(_sr(repo, "--json").output)
460 assert data["count"] == 100
461 # All refs must be sorted lexicographically.
462 names = [r["ref"] for r in data["refs"]]
463 assert names == sorted(names)
464
465 def test_100_verify_calls(self, tmp_path: pathlib.Path) -> None:
466 repo = _make_repo(tmp_path)
467 _write_ref(repo, "main")
468 for i in range(100):
469 result = _sr(repo, "--verify", "refs/heads/main", "--json")
470 assert result.exit_code == 0, f"failed at iteration {i}"
471 assert json.loads(result.output)["exists"] is True
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago