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