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