gabriel / muse public
test_cmd_rev_list.py python
675 lines 24.9 KB
Raw
sha256:3788deb0ce8d6f235a23185f7f5cd65785ecdb41b0a7f2b0107adebf45c09221 update tests/test_cmd_reset_hardening.py and tests/test_cmd… Human 3 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 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 — first-parent
357 # ---------------------------------------------------------------------------
358
359
360 def test_rev_list_first_parent(tmp_path: pathlib.Path) -> None:
361 """--first-parent only follows the first-parent chain."""
362 from muse.cli.app import main as cli
363 repo, base_ids = _fresh_repo(tmp_path, n=2)
364 saved = os.getcwd()
365 try:
366 os.chdir(repo)
367 runner.invoke(cli, ["checkout", "-b", "feat"])
368 _commit(repo, "feat work", filename="feat.py")
369 runner.invoke(cli, ["checkout", "main"])
370 runner.invoke(cli, ["merge", "feat"])
371 finally:
372 os.chdir(saved)
373
374 result_all = _invoke(repo, "--count", "HEAD")
375 result_fp = _invoke(repo, "--first-parent", "--count", "HEAD")
376 assert int(result_fp.stdout.strip()) <= int(result_all.stdout.strip())
377
378
379 # ---------------------------------------------------------------------------
380 # Security
381 # ---------------------------------------------------------------------------
382
383
384 def test_rev_list_ref_not_found_exits_nonzero(tmp_path: pathlib.Path) -> None:
385 repo, _ = _fresh_repo(tmp_path, n=1)
386 result = _invoke(repo, "nonexistent-branch")
387 assert result.exit_code != 0
388
389
390 def test_rev_list_author_regex_special_chars_handled(tmp_path: pathlib.Path) -> None:
391 """Malformed regex in --author must produce a clean error, not a crash."""
392 repo, _ = _fresh_repo(tmp_path, n=1)
393 result = _invoke(repo, "--author", "[invalid-regex", "--count", "HEAD")
394 # Should either work (literal match fallback) or exit with a clean error code
395 assert result.exit_code in (0, 1, 2)
396
397
398 def test_rev_list_touches_path_traversal_rejected(tmp_path: pathlib.Path) -> None:
399 """--touches with path traversal sequences must be rejected."""
400 repo, _ = _fresh_repo(tmp_path, n=1)
401 result = _invoke(repo, "--touches", "../../../etc/passwd", "--count", "HEAD")
402 assert result.exit_code != 0
403
404
405 # ---------------------------------------------------------------------------
406 # Stress
407 # ---------------------------------------------------------------------------
408
409
410 def test_rev_list_count_flat_memory_large_chain(tmp_path: pathlib.Path) -> None:
411 """--count on a 500-commit chain must complete without building a list."""
412 import tracemalloc
413 repo = tmp_path / "repo"
414 _init(repo)
415 for i in range(500):
416 (repo / f"f{i}.py").write_text(f"# {i}\n")
417 saved = os.getcwd()
418 try:
419 os.chdir(repo)
420 runner.invoke(cli_main(), ["code", "add", f"f{i}.py"])
421 runner.invoke(cli_main(), ["commit", "-m", f"c{i}"])
422 finally:
423 os.chdir(saved)
424
425 tracemalloc.start()
426 result = _invoke(repo, "--count", "HEAD")
427 _, peak = tracemalloc.get_traced_memory()
428 tracemalloc.stop()
429
430 assert result.stdout.strip() == "500"
431 # Peak memory for --count should stay well under 50 MB
432 assert peak < 50 * 1024 * 1024, f"Peak memory {peak // 1024} KB exceeds limit"
433
434
435 def cli_main() -> "Callable[..., None]":
436 from muse.cli.app import main
437 return main
438
439
440 def test_rev_list_stress_touches_large_repo(tmp_path: pathlib.Path) -> None:
441 """--touches on a 100-file, 50-commit repo completes without error."""
442 repo = tmp_path / "repo"
443 _init(repo)
444 for i in range(50):
445 fname = f"file_{i % 10}.py" # 10 files, cycling
446 (repo / fname).write_text(f"# iteration {i}\n")
447 saved = os.getcwd()
448 try:
449 os.chdir(repo)
450 runner.invoke(cli_main(), ["code", "add", fname])
451 runner.invoke(cli_main(), ["commit", "-m", f"c{i}"])
452 finally:
453 os.chdir(saved)
454
455 result = _invoke(repo, "--touches", "file_0.py", "--count", "HEAD")
456 assert result.exit_code == 0
457 count = int(result.stdout.strip())
458 assert count >= 5 # file_0 touched at commits 0, 10, 20, 30, 40
459
460
461 # ---------------------------------------------------------------------------
462 # JSON schema — duration_ms + exit_code on all output paths
463 # ---------------------------------------------------------------------------
464
465
466 class TestJsonSchema:
467 """--json output must carry duration_ms and exit_code on every path."""
468
469 def test_success_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
470 repo, _ = _fresh_repo(tmp_path, n=2)
471 result = _invoke(repo, "--json", "HEAD")
472 assert result.exit_code == 0
473 d = json.loads(result.stdout)
474 assert "duration_ms" in d, "duration_ms missing from --json output"
475 assert isinstance(d["duration_ms"], (int, float))
476 assert d["duration_ms"] >= 0
477
478 def test_success_has_exit_code_zero(self, tmp_path: pathlib.Path) -> None:
479 repo, _ = _fresh_repo(tmp_path, n=2)
480 result = _invoke(repo, "--json", "HEAD")
481 d = json.loads(result.stdout)
482 assert "exit_code" in d, "exit_code missing from --json output"
483 assert d["exit_code"] == 0
484
485 def test_empty_result_json_has_schema(self, tmp_path: pathlib.Path) -> None:
486 """When --after filters everything out, --json still emits a full envelope."""
487 repo, _ = _fresh_repo(tmp_path, n=2)
488 result = _invoke(repo, "--json", "--after", "2099-01-01", "HEAD")
489 d = json.loads(result.stdout)
490 assert d["commit_ids"] == []
491 assert "duration_ms" in d
492 assert "exit_code" in d
493
494 def test_reverse_json_has_schema(self, tmp_path: pathlib.Path) -> None:
495 repo, ids = _fresh_repo(tmp_path, n=3)
496 result = _invoke(repo, "--json", "--reverse", "HEAD")
497 d = json.loads(result.stdout)
498 assert "duration_ms" in d
499 assert d["exit_code"] == 0
500 assert d["commit_ids"][0] == ids[0] # oldest-first
501
502
503 # ---------------------------------------------------------------------------
504 # --count --json — structured output instead of bare integer
505 # ---------------------------------------------------------------------------
506
507
508 class TestCountJson:
509 """--count --json must emit a JSON dict, not a bare integer."""
510
511 def test_count_json_is_valid_json(self, tmp_path: pathlib.Path) -> None:
512 repo, _ = _fresh_repo(tmp_path, n=3)
513 result = _invoke(repo, "--count", "--json", "HEAD")
514 assert result.exit_code == 0
515 # Must parse as JSON — not a bare integer
516 d = json.loads(result.stdout)
517 assert isinstance(d, dict)
518
519 def test_count_json_has_count_key(self, tmp_path: pathlib.Path) -> None:
520 repo, _ = _fresh_repo(tmp_path, n=4)
521 result = _invoke(repo, "--count", "--json", "HEAD")
522 d = json.loads(result.stdout)
523 assert "count" in d
524 assert d["count"] == 4
525
526 def test_count_json_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
527 repo, _ = _fresh_repo(tmp_path, n=2)
528 result = _invoke(repo, "--count", "--json", "HEAD")
529 d = json.loads(result.stdout)
530 assert "duration_ms" in d
531 assert isinstance(d["duration_ms"], (int, float))
532
533 def test_count_json_has_exit_code_zero(self, tmp_path: pathlib.Path) -> None:
534 repo, _ = _fresh_repo(tmp_path, n=2)
535 result = _invoke(repo, "--count", "--json", "HEAD")
536 d = json.loads(result.stdout)
537 assert d["exit_code"] == 0
538
539 def test_count_without_json_still_plain_int(self, tmp_path: pathlib.Path) -> None:
540 """--count alone (no --json) must emit a bare integer, not a JSON dict."""
541 repo, _ = _fresh_repo(tmp_path, n=3)
542 result = _invoke(repo, "--count", "HEAD")
543 assert result.exit_code == 0
544 assert result.stdout.strip() == "3"
545 # Confirm it is NOT a structured JSON dict (bare ints are valid JSON but
546 # agents relying on --count should not get a dict without --json).
547 parsed = json.loads(result.stdout)
548 assert not isinstance(parsed, dict), "plain --count must not emit a JSON dict"
549
550 def test_count_json_with_filter(self, tmp_path: pathlib.Path) -> None:
551 """--count --json works correctly with a filter applied."""
552 repo = tmp_path / "repo"
553 _init(repo)
554 _commit(repo, "alice A", author="Alice")
555 _commit(repo, "bob B", author="Bob")
556 _commit(repo, "alice C", author="Alice")
557 result = _invoke(repo, "--count", "--json", "--author", "Alice", "HEAD")
558 d = json.loads(result.stdout)
559 assert d["count"] == 2
560
561
562 # ---------------------------------------------------------------------------
563 # Error JSON — all error exit paths emit structured JSON when --json is set
564 # ---------------------------------------------------------------------------
565
566
567 class TestErrorJson:
568 """Every error path must emit a parseable JSON envelope when --json is passed."""
569
570 def _assert_error_json(self, result: InvokeResult) -> Mapping[str, object]:
571 assert result.exit_code != 0, "expected non-zero exit on error"
572 d = json.loads(result.stdout)
573 assert "error" in d, f"error key missing: {d}"
574 assert "exit_code" in d
575 assert d["exit_code"] != 0
576 assert "duration_ms" in d
577 return d
578
579 def test_bad_ref_json(self, tmp_path: pathlib.Path) -> None:
580 repo, _ = _fresh_repo(tmp_path, n=1)
581 result = _invoke(repo, "--json", "nonexistent-ref")
582 self._assert_error_json(result)
583
584 def test_mutual_exclusion_json(self, tmp_path: pathlib.Path) -> None:
585 repo, _ = _fresh_repo(tmp_path, n=1)
586 result = _invoke(repo, "--json", "--no-merges", "--merges", "HEAD")
587 self._assert_error_json(result)
588
589 def test_touches_traversal_json(self, tmp_path: pathlib.Path) -> None:
590 repo, _ = _fresh_repo(tmp_path, n=1)
591 result = _invoke(repo, "--json", "--touches", "../etc/passwd", "HEAD")
592 self._assert_error_json(result)
593
594 def test_bad_after_date_json(self, tmp_path: pathlib.Path) -> None:
595 repo, _ = _fresh_repo(tmp_path, n=1)
596 result = _invoke(repo, "--json", "--after", "not-a-date", "HEAD")
597 self._assert_error_json(result)
598
599 def test_bad_before_date_json(self, tmp_path: pathlib.Path) -> None:
600 repo, _ = _fresh_repo(tmp_path, n=1)
601 result = _invoke(repo, "--json", "--before", "not-a-date", "HEAD")
602 self._assert_error_json(result)
603
604 def test_bad_exclude_ref_json(self, tmp_path: pathlib.Path) -> None:
605 """A..B where A is invalid must also emit structured JSON error."""
606 repo, _ = _fresh_repo(tmp_path, n=1)
607 result = _invoke(repo, "--json", "nonexistent..HEAD")
608 self._assert_error_json(result)
609
610 def test_error_json_has_message(self, tmp_path: pathlib.Path) -> None:
611 repo, _ = _fresh_repo(tmp_path, n=1)
612 result = _invoke(repo, "--json", "nonexistent-ref")
613 d = json.loads(result.stdout)
614 assert "message" in d
615 assert isinstance(d["message"], str)
616 assert len(d["message"]) > 0
617
618
619 # ---------------------------------------------------------------------------
620 # --after predicate precedence — latent bug guard
621 # ---------------------------------------------------------------------------
622
623
624 class TestAfterPredicate:
625 """Guard against regression in --after predicate operator precedence."""
626
627 def test_after_far_future_excludes_all(self, tmp_path: pathlib.Path) -> None:
628 """--after 2099-01-01 must match zero commits regardless of tzinfo state."""
629 repo, _ = _fresh_repo(tmp_path, n=3)
630 result = _invoke(repo, "--after", "2099-01-01", "--count", "HEAD")
631 assert result.exit_code == 0
632 assert result.stdout.strip() == "0"
633
634 def test_after_far_past_keeps_all(self, tmp_path: pathlib.Path) -> None:
635 repo, _ = _fresh_repo(tmp_path, n=3)
636 result = _invoke(repo, "--after", "2000-01-01", "--count", "HEAD")
637 assert result.exit_code == 0
638 assert result.stdout.strip() == "3"
639
640 def test_after_json_far_future_zero(self, tmp_path: pathlib.Path) -> None:
641 """--after --json with no matches emits commit_ids:[] not a crash."""
642 repo, _ = _fresh_repo(tmp_path, n=2)
643 result = _invoke(repo, "--json", "--after", "2099-01-01", "HEAD")
644 assert result.exit_code == 0
645 d = json.loads(result.stdout)
646 assert d["commit_ids"] == []
647
648
649 class TestRegisterFlags:
650 def test_default_json_out_is_false(self) -> None:
651 import argparse
652 from muse.cli.commands.rev_list import register
653 p = argparse.ArgumentParser()
654 subs = p.add_subparsers()
655 register(subs)
656 args = p.parse_args(["rev-list"])
657 assert args.json_out is False
658
659 def test_json_flag_sets_json_out(self) -> None:
660 import argparse
661 from muse.cli.commands.rev_list import register
662 p = argparse.ArgumentParser()
663 subs = p.add_subparsers()
664 register(subs)
665 args = p.parse_args(["rev-list", "--json"])
666 assert args.json_out is True
667
668 def test_j_shorthand_sets_json_out(self) -> None:
669 import argparse
670 from muse.cli.commands.rev_list import register
671 p = argparse.ArgumentParser()
672 subs = p.add_subparsers()
673 register(subs)
674 args = p.parse_args(["rev-list", "-j"])
675 assert args.json_out is True
File History 1 commit
sha256:3788deb0ce8d6f235a23185f7f5cd65785ecdb41b0a7f2b0107adebf45c09221 update tests/test_cmd_reset_hardening.py and tests/test_cmd… Human 3 days ago