gabriel / muse public
test_cmd_rev_list.py python
777 lines 28.3 KB
Raw
sha256:85fc1542f909649acadf0b5fd3bc662f43d59eea76d799a069e76cbccd54f7ec feat: implement ^ref exclusion prefix syntax in rev-list an… Sonnet 5 patch 7 hours ago
1 """Tests for ``muse rev-list`` — raw commit ID stream with filters.
2
3 Coverage tiers:
4 - Unit: _walk_from, _parse_range, _parse_date, filter predicates
5 - Integration: --count, --max-count, --first-parent, --no-merges, --merges,
6 --author, --after, --before, --touches, --reverse, --json,
7 A..B range syntax
8 - End-to-end: full CLI invocation via CliRunner
9 - Security: ref injection, --touches path traversal, --author regex injection
10 - Stress: 500-commit chain with --count (flat memory), --touches on large repo
11 """
12 from __future__ import annotations
13 from collections.abc import Callable, Mapping
14
15 import json
16 import os
17 import pathlib
18
19 import pytest
20
21 from muse.core.types import split_id
22 from tests.cli_test_helper import CliRunner, InvokeResult
23
24 runner = CliRunner()
25
26
27 # ---------------------------------------------------------------------------
28 # Helpers
29 # ---------------------------------------------------------------------------
30
31
32 def _invoke(repo: pathlib.Path, *args: str) -> InvokeResult:
33 from muse.cli.app import main as cli
34 saved = os.getcwd()
35 try:
36 os.chdir(repo)
37 return runner.invoke(cli, ["rev-list", *args])
38 finally:
39 os.chdir(saved)
40
41
42 def _init(repo: pathlib.Path) -> None:
43 from muse.cli.app import main as cli
44 repo.mkdir(parents=True, exist_ok=True)
45 saved = os.getcwd()
46 try:
47 os.chdir(repo)
48 runner.invoke(cli, ["init"])
49 finally:
50 os.chdir(saved)
51
52
53 def _commit(
54 repo: pathlib.Path,
55 msg: str = "commit",
56 filename: str | None = None,
57 author: str | None = None,
58 ) -> str:
59 """Commit one file and return the commit_id."""
60 from muse.cli.app import main as cli
61 fname = filename or f"f_{abs(hash(msg)) % 99999}.py"
62 (repo / fname).write_text(f"# {msg}\n")
63 saved = os.getcwd()
64 try:
65 os.chdir(repo)
66 runner.invoke(cli, ["code", "add", fname])
67 extra = ["--author", author] if author else []
68 result = runner.invoke(cli, ["commit", "-m", msg, "--json", *extra])
69 data = json.loads(result.stdout)
70 return data["commit_id"]
71 finally:
72 os.chdir(saved)
73
74
75 def _fresh_repo(tmp: pathlib.Path, n: int = 3) -> tuple[pathlib.Path, list[str]]:
76 """Create a repo with n commits, return (repo_path, [commit_ids oldest→newest])."""
77 repo = tmp / "repo"
78 _init(repo)
79 ids: list[str] = []
80 for i in range(n):
81 cid = _commit(repo, f"commit {i}", filename=f"file_{i}.py")
82 ids.append(cid)
83 return repo, ids
84
85
86 # ---------------------------------------------------------------------------
87 # Unit — internal helpers
88 # ---------------------------------------------------------------------------
89
90
91 def test_walk_from_uses_deque() -> None:
92 """_walk_from must use collections.deque; no variable.pop(0) calls in code."""
93 import inspect, ast
94 from muse.cli.commands import rev_list as mod
95 src = inspect.getsource(mod._walk_from)
96 assert "deque" in src, "_walk_from must use collections.deque"
97 # Parse the AST to check for list.pop(0) calls — this skips docstrings.
98 tree = ast.parse(src)
99 for node in ast.walk(tree):
100 if (
101 isinstance(node, ast.Call)
102 and isinstance(node.func, ast.Attribute)
103 and node.func.attr == "pop"
104 and len(node.args) == 1
105 and isinstance(node.args[0], ast.Constant)
106 and node.args[0].value == 0
107 ):
108 raise AssertionError("_walk_from must not call .pop(0) — use deque.popleft()")
109
110
111 def test_parse_range_dotdot() -> None:
112 """'A..B' must be parsed into (exclude='A', include='B')."""
113 from muse.cli.commands.rev_list import _parse_range
114 exc, inc = _parse_range("abc..def")
115 assert exc == "abc"
116 assert inc == "def"
117
118
119 def test_parse_range_single() -> None:
120 """A plain ref with no '..' must parse to (exclude=None, include=ref)."""
121 from muse.cli.commands.rev_list import _parse_range
122 exc, inc = _parse_range("HEAD")
123 assert exc is None
124 assert inc == "HEAD"
125
126
127 def test_parse_date_valid() -> None:
128 from muse.cli.commands.rev_list import _parse_date
129 import datetime
130 dt = _parse_date("2026-01-15")
131 assert dt.year == 2026
132 assert dt.month == 1
133 assert dt.day == 15
134 assert dt.tzinfo == datetime.timezone.utc
135
136
137 def test_parse_date_invalid_raises() -> None:
138 from muse.cli.commands.rev_list import _parse_date
139 with pytest.raises(ValueError, match="date"):
140 _parse_date("not-a-date")
141
142
143 # ---------------------------------------------------------------------------
144 # Integration — basic output
145 # ---------------------------------------------------------------------------
146
147
148 def test_rev_list_emits_one_id_per_line(tmp_path: pathlib.Path) -> None:
149 repo, ids = _fresh_repo(tmp_path, n=3)
150 result = _invoke(repo, "HEAD")
151 assert result.exit_code == 0
152 lines = [l for l in result.stdout.strip().splitlines() if l]
153 assert len(lines) == 3
154 # Each line must be a sha256:-prefixed commit ID (7 prefix + 64 hex chars = 71)
155 for line in lines:
156 assert line.startswith("sha256:"), f"expected sha256: prefix, got {line!r}"
157 _, hex_part = split_id(line)
158 assert len(hex_part) == 64, f"expected 64-char hex after prefix, got {len(hex_part)}"
159 int(hex_part, 16)
160
161
162 def test_rev_list_newest_first(tmp_path: pathlib.Path) -> None:
163 repo, ids = _fresh_repo(tmp_path, n=3)
164 result = _invoke(repo, "HEAD")
165 lines = [l for l in result.stdout.strip().splitlines() if l]
166 # ids list is oldest→newest; rev-list default is newest→oldest
167 assert lines[0] == ids[-1]
168 assert lines[-1] == ids[0]
169
170
171 def test_rev_list_count(tmp_path: pathlib.Path) -> None:
172 repo, ids = _fresh_repo(tmp_path, n=5)
173 result = _invoke(repo, "--count", "HEAD")
174 assert result.exit_code == 0
175 assert result.stdout.strip() == "5"
176
177
178 def test_rev_list_max_count(tmp_path: pathlib.Path) -> None:
179 repo, ids = _fresh_repo(tmp_path, n=5)
180 result = _invoke(repo, "--max-count", "2", "HEAD")
181 lines = [l for l in result.stdout.strip().splitlines() if l]
182 assert len(lines) == 2
183 assert lines[0] == ids[-1] # most recent
184
185
186 def test_rev_list_reverse(tmp_path: pathlib.Path) -> None:
187 repo, ids = _fresh_repo(tmp_path, n=3)
188 result = _invoke(repo, "--reverse", "HEAD")
189 lines = [l for l in result.stdout.strip().splitlines() if l]
190 assert lines[0] == ids[0] # oldest first
191 assert lines[-1] == ids[-1] # newest last
192
193
194 def test_rev_list_json(tmp_path: pathlib.Path) -> None:
195 repo, ids = _fresh_repo(tmp_path, n=3)
196 result = _invoke(repo, "--json", "HEAD")
197 assert result.exit_code == 0
198 data = json.loads(result.stdout)
199 assert "commit_ids" in data
200 assert len(data["commit_ids"]) == 3
201 assert data["commit_ids"][0] == ids[-1]
202
203
204 # ---------------------------------------------------------------------------
205 # Integration — filter flags
206 # ---------------------------------------------------------------------------
207
208
209 def _make_merge_commit(repo: pathlib.Path) -> None:
210 """Create a divergent history and merge it, producing a real merge commit."""
211 from muse.cli.app import main as cli
212 saved = os.getcwd()
213 try:
214 os.chdir(repo)
215 runner.invoke(cli, ["checkout", "-b", "feat"])
216 _commit(repo, "feat work", filename="feat_only.py")
217 runner.invoke(cli, ["checkout", "main"])
218 # Commit on main so histories diverge → true merge commit (not FF)
219 _commit(repo, "main diverge", filename="main_only.py")
220 runner.invoke(cli, ["merge", "feat"])
221 finally:
222 os.chdir(saved)
223
224
225 def test_rev_list_no_merges(tmp_path: pathlib.Path) -> None:
226 """--no-merges excludes commits that have two parents."""
227 repo, ids = _fresh_repo(tmp_path, n=2)
228 _make_merge_commit(repo)
229
230 result_all = _invoke(repo, "--count", "HEAD")
231 result_no_merges = _invoke(repo, "--no-merges", "--count", "HEAD")
232 total = int(result_all.stdout.strip())
233 no_merge_count = int(result_no_merges.stdout.strip())
234 assert no_merge_count < total
235
236
237 def test_rev_list_merges_only(tmp_path: pathlib.Path) -> None:
238 """--merges emits only merge commits."""
239 repo, ids = _fresh_repo(tmp_path, n=2)
240 _make_merge_commit(repo)
241
242 result = _invoke(repo, "--merges", "--count", "HEAD")
243 assert int(result.stdout.strip()) >= 1
244
245
246 def test_rev_list_author_filter(tmp_path: pathlib.Path) -> None:
247 repo = tmp_path / "repo"
248 _init(repo)
249 _commit(repo, "alice commit", author="Alice")
250 _commit(repo, "bob commit", author="Bob")
251 _commit(repo, "alice again", author="Alice")
252
253 result = _invoke(repo, "--author", "Alice", "--count", "HEAD")
254 assert result.exit_code == 0
255 assert result.stdout.strip() == "2"
256
257
258 def test_rev_list_after_filter(tmp_path: pathlib.Path) -> None:
259 """--after excludes commits before the date."""
260 repo, ids = _fresh_repo(tmp_path, n=3)
261 # All commits are in the future (2026) so --after 2020-01-01 keeps all
262 result_all = _invoke(repo, "--count", "HEAD")
263 result_after = _invoke(repo, "--after", "2020-01-01", "--count", "HEAD")
264 assert result_all.stdout.strip() == result_after.stdout.strip()
265
266 # --after 2099-01-01 should keep nothing
267 result_future = _invoke(repo, "--after", "2099-01-01", "--count", "HEAD")
268 assert result_future.stdout.strip() == "0"
269
270
271 def test_rev_list_before_filter(tmp_path: pathlib.Path) -> None:
272 """--before excludes commits after the date."""
273 repo, ids = _fresh_repo(tmp_path, n=3)
274 result_before = _invoke(repo, "--before", "2099-01-01", "--count", "HEAD")
275 assert int(result_before.stdout.strip()) == 3
276
277 result_past = _invoke(repo, "--before", "2020-01-01", "--count", "HEAD")
278 assert result_past.stdout.strip() == "0"
279
280
281 def test_rev_list_touches_filter(tmp_path: pathlib.Path) -> None:
282 """--touches only emits commits that changed the specified path."""
283 repo = tmp_path / "repo"
284 _init(repo)
285 _commit(repo, "add alpha", filename="alpha.py")
286 _commit(repo, "add beta", filename="beta.py")
287 _commit(repo, "modify alpha", filename="alpha.py")
288
289 result = _invoke(repo, "--touches", "alpha.py", "--count", "HEAD")
290 assert result.exit_code == 0
291 assert result.stdout.strip() == "2"
292
293
294 def test_rev_list_touches_directory_prefix(tmp_path: pathlib.Path) -> None:
295 """--touches src/ matches all files under src/."""
296 repo = tmp_path / "repo"
297 _init(repo)
298 (repo / "src").mkdir()
299 _commit(repo, "src file", filename="src/main.py")
300 _commit(repo, "root file", filename="root.py")
301 _commit(repo, "src again", filename="src/utils.py")
302
303 result = _invoke(repo, "--touches", "src/", "--count", "HEAD")
304 assert result.exit_code == 0
305 assert result.stdout.strip() == "2"
306
307
308 # ---------------------------------------------------------------------------
309 # Integration — range syntax
310 # ---------------------------------------------------------------------------
311
312
313 def test_rev_list_range_syntax(tmp_path: pathlib.Path) -> None:
314 """A..B emits commits reachable from B but not from A."""
315 from muse.cli.app import main as cli
316 repo, base_ids = _fresh_repo(tmp_path, n=2)
317
318 saved = os.getcwd()
319 try:
320 os.chdir(repo)
321 runner.invoke(cli, ["checkout", "-b", "feat"])
322 finally:
323 os.chdir(saved)
324
325 feat_id1 = _commit(repo, "feat 1", filename="feat1.py")
326 feat_id2 = _commit(repo, "feat 2", filename="feat2.py")
327
328 result = _invoke(repo, "main..feat")
329 lines = [l for l in result.stdout.strip().splitlines() if l]
330 assert len(lines) == 2
331 assert feat_id2 in lines
332 assert feat_id1 in lines
333 # Base commits must NOT appear
334 for base_id in base_ids:
335 assert base_id not in lines
336
337
338 def test_rev_list_range_count(tmp_path: pathlib.Path) -> None:
339 """--count with range counts only the range, not the full history."""
340 from muse.cli.app import main as cli
341 repo, _ = _fresh_repo(tmp_path, n=3)
342 saved = os.getcwd()
343 try:
344 os.chdir(repo)
345 runner.invoke(cli, ["checkout", "-b", "feat"])
346 finally:
347 os.chdir(saved)
348 _commit(repo, "feat A", filename="fa.py")
349 _commit(repo, "feat B", filename="fb.py")
350
351 result = _invoke(repo, "--count", "main..feat")
352 assert result.stdout.strip() == "2"
353
354
355 # ---------------------------------------------------------------------------
356 # Integration — "^ref" exclusion prefix (muse#31 EX_01 / EX_02)
357 # ---------------------------------------------------------------------------
358
359
360 def test_EX_01_caret_exclusion_equivalent_to_dotdot_range(tmp_path: pathlib.Path) -> None:
361 """``rev-list feat ^main`` emits the same commits as ``rev-list main..feat``."""
362 from muse.cli.app import main as cli
363 repo, base_ids = _fresh_repo(tmp_path, n=2)
364
365 saved = os.getcwd()
366 try:
367 os.chdir(repo)
368 runner.invoke(cli, ["checkout", "-b", "feat"])
369 finally:
370 os.chdir(saved)
371
372 feat_id1 = _commit(repo, "feat 1", filename="feat1.py")
373 feat_id2 = _commit(repo, "feat 2", filename="feat2.py")
374
375 caret_result = _invoke(repo, "feat", "^main")
376 range_result = _invoke(repo, "main..feat")
377
378 caret_lines = sorted(l for l in caret_result.stdout.strip().splitlines() if l)
379 range_lines = sorted(l for l in range_result.stdout.strip().splitlines() if l)
380
381 assert caret_lines == range_lines
382 assert feat_id1 in caret_lines
383 assert feat_id2 in caret_lines
384 for base_id in base_ids:
385 assert base_id not in caret_lines
386
387
388 def test_EX_02_multiple_caret_exclusions_compose(tmp_path: pathlib.Path) -> None:
389 """``rev-list A ^B ^C`` excludes everything reachable from B OR C."""
390 from muse.cli.app import main as cli
391 repo, base_ids = _fresh_repo(tmp_path, n=1)
392
393 saved = os.getcwd()
394 try:
395 os.chdir(repo)
396 runner.invoke(cli, ["checkout", "-b", "branch-b"])
397 finally:
398 os.chdir(saved)
399 b_only_id = _commit(repo, "b only", filename="b_only.py")
400
401 saved = os.getcwd()
402 try:
403 os.chdir(repo)
404 runner.invoke(cli, ["checkout", "main"])
405 runner.invoke(cli, ["checkout", "-b", "branch-c"])
406 finally:
407 os.chdir(saved)
408 c_only_id = _commit(repo, "c only", filename="c_only.py")
409
410 saved = os.getcwd()
411 try:
412 os.chdir(repo)
413 runner.invoke(cli, ["checkout", "main"])
414 runner.invoke(cli, ["checkout", "-b", "branch-a"])
415 finally:
416 os.chdir(saved)
417 a_id1 = _commit(repo, "a 1", filename="a1.py")
418 a_id2 = _commit(repo, "a 2", filename="a2.py")
419
420 result = _invoke(repo, "branch-a", "^branch-b", "^branch-c")
421 lines = [l for l in result.stdout.strip().splitlines() if l]
422
423 assert a_id1 in lines
424 assert a_id2 in lines
425 assert b_only_id not in lines
426 assert c_only_id not in lines
427 for base_id in base_ids:
428 assert base_id not in lines
429
430
431 def test_EX_01_caret_exclusion_json(tmp_path: pathlib.Path) -> None:
432 """``rev-list feat ^main --json`` returns the structured commit_ids list."""
433 from muse.cli.app import main as cli
434 repo, _ = _fresh_repo(tmp_path, n=1)
435 saved = os.getcwd()
436 try:
437 os.chdir(repo)
438 runner.invoke(cli, ["checkout", "-b", "feat"])
439 finally:
440 os.chdir(saved)
441 feat_id = _commit(repo, "feat only", filename="feat_only.py")
442
443 result = _invoke(repo, "feat", "^main", "--json")
444 data = json.loads(result.stdout)
445 assert data["commit_ids"] == [feat_id]
446
447
448 def test_EX_02_bad_caret_ref_errors(tmp_path: pathlib.Path) -> None:
449 """An unresolvable ``^ref`` reports a structured error, not a crash."""
450 repo, _ = _fresh_repo(tmp_path, n=1)
451 result = _invoke(repo, "--json", "HEAD", "^nonexistent")
452 data = json.loads(result.stdout)
453 assert "error" in data
454 assert result.exit_code != 0
455
456
457 # ---------------------------------------------------------------------------
458 # Integration — first-parent
459 # ---------------------------------------------------------------------------
460
461
462 def test_rev_list_first_parent(tmp_path: pathlib.Path) -> None:
463 """--first-parent only follows the first-parent chain."""
464 from muse.cli.app import main as cli
465 repo, base_ids = _fresh_repo(tmp_path, n=2)
466 saved = os.getcwd()
467 try:
468 os.chdir(repo)
469 runner.invoke(cli, ["checkout", "-b", "feat"])
470 _commit(repo, "feat work", filename="feat.py")
471 runner.invoke(cli, ["checkout", "main"])
472 runner.invoke(cli, ["merge", "feat"])
473 finally:
474 os.chdir(saved)
475
476 result_all = _invoke(repo, "--count", "HEAD")
477 result_fp = _invoke(repo, "--first-parent", "--count", "HEAD")
478 assert int(result_fp.stdout.strip()) <= int(result_all.stdout.strip())
479
480
481 # ---------------------------------------------------------------------------
482 # Security
483 # ---------------------------------------------------------------------------
484
485
486 def test_rev_list_ref_not_found_exits_nonzero(tmp_path: pathlib.Path) -> None:
487 repo, _ = _fresh_repo(tmp_path, n=1)
488 result = _invoke(repo, "nonexistent-branch")
489 assert result.exit_code != 0
490
491
492 def test_rev_list_author_regex_special_chars_handled(tmp_path: pathlib.Path) -> None:
493 """Malformed regex in --author must produce a clean error, not a crash."""
494 repo, _ = _fresh_repo(tmp_path, n=1)
495 result = _invoke(repo, "--author", "[invalid-regex", "--count", "HEAD")
496 # Should either work (literal match fallback) or exit with a clean error code
497 assert result.exit_code in (0, 1, 2)
498
499
500 def test_rev_list_touches_path_traversal_rejected(tmp_path: pathlib.Path) -> None:
501 """--touches with path traversal sequences must be rejected."""
502 repo, _ = _fresh_repo(tmp_path, n=1)
503 result = _invoke(repo, "--touches", "../../../etc/passwd", "--count", "HEAD")
504 assert result.exit_code != 0
505
506
507 # ---------------------------------------------------------------------------
508 # Stress
509 # ---------------------------------------------------------------------------
510
511
512 def test_rev_list_count_flat_memory_large_chain(tmp_path: pathlib.Path) -> None:
513 """--count on a 500-commit chain must complete without building a list."""
514 import tracemalloc
515 repo = tmp_path / "repo"
516 _init(repo)
517 for i in range(500):
518 (repo / f"f{i}.py").write_text(f"# {i}\n")
519 saved = os.getcwd()
520 try:
521 os.chdir(repo)
522 runner.invoke(cli_main(), ["code", "add", f"f{i}.py"])
523 runner.invoke(cli_main(), ["commit", "-m", f"c{i}"])
524 finally:
525 os.chdir(saved)
526
527 tracemalloc.start()
528 result = _invoke(repo, "--count", "HEAD")
529 _, peak = tracemalloc.get_traced_memory()
530 tracemalloc.stop()
531
532 assert result.stdout.strip() == "500"
533 # Peak memory for --count should stay well under 50 MB
534 assert peak < 50 * 1024 * 1024, f"Peak memory {peak // 1024} KB exceeds limit"
535
536
537 def cli_main() -> "Callable[..., None]":
538 from muse.cli.app import main
539 return main
540
541
542 def test_rev_list_stress_touches_large_repo(tmp_path: pathlib.Path) -> None:
543 """--touches on a 100-file, 50-commit repo completes without error."""
544 repo = tmp_path / "repo"
545 _init(repo)
546 for i in range(50):
547 fname = f"file_{i % 10}.py" # 10 files, cycling
548 (repo / fname).write_text(f"# iteration {i}\n")
549 saved = os.getcwd()
550 try:
551 os.chdir(repo)
552 runner.invoke(cli_main(), ["code", "add", fname])
553 runner.invoke(cli_main(), ["commit", "-m", f"c{i}"])
554 finally:
555 os.chdir(saved)
556
557 result = _invoke(repo, "--touches", "file_0.py", "--count", "HEAD")
558 assert result.exit_code == 0
559 count = int(result.stdout.strip())
560 assert count >= 5 # file_0 touched at commits 0, 10, 20, 30, 40
561
562
563 # ---------------------------------------------------------------------------
564 # JSON schema — duration_ms + exit_code on all output paths
565 # ---------------------------------------------------------------------------
566
567
568 class TestJsonSchema:
569 """--json output must carry duration_ms and exit_code on every path."""
570
571 def test_success_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
572 repo, _ = _fresh_repo(tmp_path, n=2)
573 result = _invoke(repo, "--json", "HEAD")
574 assert result.exit_code == 0
575 d = json.loads(result.stdout)
576 assert "duration_ms" in d, "duration_ms missing from --json output"
577 assert isinstance(d["duration_ms"], (int, float))
578 assert d["duration_ms"] >= 0
579
580 def test_success_has_exit_code_zero(self, tmp_path: pathlib.Path) -> None:
581 repo, _ = _fresh_repo(tmp_path, n=2)
582 result = _invoke(repo, "--json", "HEAD")
583 d = json.loads(result.stdout)
584 assert "exit_code" in d, "exit_code missing from --json output"
585 assert d["exit_code"] == 0
586
587 def test_empty_result_json_has_schema(self, tmp_path: pathlib.Path) -> None:
588 """When --after filters everything out, --json still emits a full envelope."""
589 repo, _ = _fresh_repo(tmp_path, n=2)
590 result = _invoke(repo, "--json", "--after", "2099-01-01", "HEAD")
591 d = json.loads(result.stdout)
592 assert d["commit_ids"] == []
593 assert "duration_ms" in d
594 assert "exit_code" in d
595
596 def test_reverse_json_has_schema(self, tmp_path: pathlib.Path) -> None:
597 repo, ids = _fresh_repo(tmp_path, n=3)
598 result = _invoke(repo, "--json", "--reverse", "HEAD")
599 d = json.loads(result.stdout)
600 assert "duration_ms" in d
601 assert d["exit_code"] == 0
602 assert d["commit_ids"][0] == ids[0] # oldest-first
603
604
605 # ---------------------------------------------------------------------------
606 # --count --json — structured output instead of bare integer
607 # ---------------------------------------------------------------------------
608
609
610 class TestCountJson:
611 """--count --json must emit a JSON dict, not a bare integer."""
612
613 def test_count_json_is_valid_json(self, tmp_path: pathlib.Path) -> None:
614 repo, _ = _fresh_repo(tmp_path, n=3)
615 result = _invoke(repo, "--count", "--json", "HEAD")
616 assert result.exit_code == 0
617 # Must parse as JSON — not a bare integer
618 d = json.loads(result.stdout)
619 assert isinstance(d, dict)
620
621 def test_count_json_has_count_key(self, tmp_path: pathlib.Path) -> None:
622 repo, _ = _fresh_repo(tmp_path, n=4)
623 result = _invoke(repo, "--count", "--json", "HEAD")
624 d = json.loads(result.stdout)
625 assert "count" in d
626 assert d["count"] == 4
627
628 def test_count_json_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
629 repo, _ = _fresh_repo(tmp_path, n=2)
630 result = _invoke(repo, "--count", "--json", "HEAD")
631 d = json.loads(result.stdout)
632 assert "duration_ms" in d
633 assert isinstance(d["duration_ms"], (int, float))
634
635 def test_count_json_has_exit_code_zero(self, tmp_path: pathlib.Path) -> None:
636 repo, _ = _fresh_repo(tmp_path, n=2)
637 result = _invoke(repo, "--count", "--json", "HEAD")
638 d = json.loads(result.stdout)
639 assert d["exit_code"] == 0
640
641 def test_count_without_json_still_plain_int(self, tmp_path: pathlib.Path) -> None:
642 """--count alone (no --json) must emit a bare integer, not a JSON dict."""
643 repo, _ = _fresh_repo(tmp_path, n=3)
644 result = _invoke(repo, "--count", "HEAD")
645 assert result.exit_code == 0
646 assert result.stdout.strip() == "3"
647 # Confirm it is NOT a structured JSON dict (bare ints are valid JSON but
648 # agents relying on --count should not get a dict without --json).
649 parsed = json.loads(result.stdout)
650 assert not isinstance(parsed, dict), "plain --count must not emit a JSON dict"
651
652 def test_count_json_with_filter(self, tmp_path: pathlib.Path) -> None:
653 """--count --json works correctly with a filter applied."""
654 repo = tmp_path / "repo"
655 _init(repo)
656 _commit(repo, "alice A", author="Alice")
657 _commit(repo, "bob B", author="Bob")
658 _commit(repo, "alice C", author="Alice")
659 result = _invoke(repo, "--count", "--json", "--author", "Alice", "HEAD")
660 d = json.loads(result.stdout)
661 assert d["count"] == 2
662
663
664 # ---------------------------------------------------------------------------
665 # Error JSON — all error exit paths emit structured JSON when --json is set
666 # ---------------------------------------------------------------------------
667
668
669 class TestErrorJson:
670 """Every error path must emit a parseable JSON envelope when --json is passed."""
671
672 def _assert_error_json(self, result: InvokeResult) -> Mapping[str, object]:
673 assert result.exit_code != 0, "expected non-zero exit on error"
674 d = json.loads(result.stdout)
675 assert "error" in d, f"error key missing: {d}"
676 assert "exit_code" in d
677 assert d["exit_code"] != 0
678 assert "duration_ms" in d
679 return d
680
681 def test_bad_ref_json(self, tmp_path: pathlib.Path) -> None:
682 repo, _ = _fresh_repo(tmp_path, n=1)
683 result = _invoke(repo, "--json", "nonexistent-ref")
684 self._assert_error_json(result)
685
686 def test_mutual_exclusion_json(self, tmp_path: pathlib.Path) -> None:
687 repo, _ = _fresh_repo(tmp_path, n=1)
688 result = _invoke(repo, "--json", "--no-merges", "--merges", "HEAD")
689 self._assert_error_json(result)
690
691 def test_touches_traversal_json(self, tmp_path: pathlib.Path) -> None:
692 repo, _ = _fresh_repo(tmp_path, n=1)
693 result = _invoke(repo, "--json", "--touches", "../etc/passwd", "HEAD")
694 self._assert_error_json(result)
695
696 def test_bad_after_date_json(self, tmp_path: pathlib.Path) -> None:
697 repo, _ = _fresh_repo(tmp_path, n=1)
698 result = _invoke(repo, "--json", "--after", "not-a-date", "HEAD")
699 self._assert_error_json(result)
700
701 def test_bad_before_date_json(self, tmp_path: pathlib.Path) -> None:
702 repo, _ = _fresh_repo(tmp_path, n=1)
703 result = _invoke(repo, "--json", "--before", "not-a-date", "HEAD")
704 self._assert_error_json(result)
705
706 def test_bad_exclude_ref_json(self, tmp_path: pathlib.Path) -> None:
707 """A..B where A is invalid must also emit structured JSON error."""
708 repo, _ = _fresh_repo(tmp_path, n=1)
709 result = _invoke(repo, "--json", "nonexistent..HEAD")
710 self._assert_error_json(result)
711
712 def test_error_json_has_message(self, tmp_path: pathlib.Path) -> None:
713 repo, _ = _fresh_repo(tmp_path, n=1)
714 result = _invoke(repo, "--json", "nonexistent-ref")
715 d = json.loads(result.stdout)
716 assert "message" in d
717 assert isinstance(d["message"], str)
718 assert len(d["message"]) > 0
719
720
721 # ---------------------------------------------------------------------------
722 # --after predicate precedence — latent bug guard
723 # ---------------------------------------------------------------------------
724
725
726 class TestAfterPredicate:
727 """Guard against regression in --after predicate operator precedence."""
728
729 def test_after_far_future_excludes_all(self, tmp_path: pathlib.Path) -> None:
730 """--after 2099-01-01 must match zero commits regardless of tzinfo state."""
731 repo, _ = _fresh_repo(tmp_path, n=3)
732 result = _invoke(repo, "--after", "2099-01-01", "--count", "HEAD")
733 assert result.exit_code == 0
734 assert result.stdout.strip() == "0"
735
736 def test_after_far_past_keeps_all(self, tmp_path: pathlib.Path) -> None:
737 repo, _ = _fresh_repo(tmp_path, n=3)
738 result = _invoke(repo, "--after", "2000-01-01", "--count", "HEAD")
739 assert result.exit_code == 0
740 assert result.stdout.strip() == "3"
741
742 def test_after_json_far_future_zero(self, tmp_path: pathlib.Path) -> None:
743 """--after --json with no matches emits commit_ids:[] not a crash."""
744 repo, _ = _fresh_repo(tmp_path, n=2)
745 result = _invoke(repo, "--json", "--after", "2099-01-01", "HEAD")
746 assert result.exit_code == 0
747 d = json.loads(result.stdout)
748 assert d["commit_ids"] == []
749
750
751 class TestRegisterFlags:
752 def test_default_json_out_is_false(self) -> None:
753 import argparse
754 from muse.cli.commands.rev_list import register
755 p = argparse.ArgumentParser()
756 subs = p.add_subparsers()
757 register(subs)
758 args = p.parse_args(["rev-list"])
759 assert args.json_out is False
760
761 def test_json_flag_sets_json_out(self) -> None:
762 import argparse
763 from muse.cli.commands.rev_list import register
764 p = argparse.ArgumentParser()
765 subs = p.add_subparsers()
766 register(subs)
767 args = p.parse_args(["rev-list", "--json"])
768 assert args.json_out is True
769
770 def test_j_shorthand_sets_json_out(self) -> None:
771 import argparse
772 from muse.cli.commands.rev_list import register
773 p = argparse.ArgumentParser()
774 subs = p.add_subparsers()
775 register(subs)
776 args = p.parse_args(["rev-list", "-j"])
777 assert args.json_out is True
File History 1 commit
sha256:85fc1542f909649acadf0b5fd3bc662f43d59eea76d799a069e76cbccd54f7ec feat: implement ^ref exclusion prefix syntax in rev-list an… Sonnet 5 patch 7 hours ago