gabriel / muse public
test_cmd_shortlog_hardening.py python
864 lines 30.5 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
1 """Hardening test suite for ``muse shortlog``.
2
3 Coverage:
4 - Unit: _branch_names (symlink guard), _group_key (all four modes),
5 _build_groups (email flag, dedup), _parse_date (valid + invalid)
6 - Security: ANSI in author/message sanitized in text, raw in JSON;
7 symlink inside refs/heads is skipped
8 - Error routing: all user errors go to stderr
9 - JSON schema: _ShortlogJson shape (repo_id, branch, groups), all fields
10 - New flags: --group-by (agent, model, branch), --summary, --no-merges,
11 --since, --until, combined filters
12 - --json: empty, single group, multi-group, provenance fields
13 - Integration: --all branches with dedup, --limit early-exit, date range
14 - E2E: help output, combined flags
15 - Stress: 500 commits × 5 authors, 50-branch repo, concurrent reads
16 """
17
18 from __future__ import annotations
19 from collections.abc import Mapping
20
21 import datetime
22 import json
23 import os
24 import pathlib
25 from typing import TypedDict
26 from unittest.mock import patch
27
28 import pytest
29 from tests.cli_test_helper import CliRunner, InvokeResult
30
31 from muse.cli.commands.shortlog import _branch_names, _build_groups, _group_key, _parse_date
32 from muse.core.object_store import write_object
33 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
34 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
35 from muse.core._types import Manifest, blob_id
36
37 runner = CliRunner()
38 _REPO_ID = "shortlog-hard-test"
39
40 # Tracks the latest commit_id per (str(root), branch) so _make_commit
41 # can auto-chain without callers needing to pass parent_id explicitly.
42 _branch_heads_map: Manifest = {}
43
44
45 # ---------------------------------------------------------------------------
46 # Helpers
47 # ---------------------------------------------------------------------------
48
49
50 def _sha(data: bytes) -> str:
51 return blob_id(data)
52
53
54 def _init_repo(path: pathlib.Path, *, domain: str = "code") -> pathlib.Path:
55 muse = path / ".muse"
56 for sub in ("commits", "snapshots", "objects", "refs/heads"):
57 (muse / sub).mkdir(parents=True, exist_ok=True)
58 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
59 (muse / "repo.json").write_text(
60 json.dumps({"repo_id": _REPO_ID, "domain": domain}),
61 encoding="utf-8",
62 )
63 return path
64
65
66 _commit_counter = 0
67
68
69 def _make_commit(
70 root: pathlib.Path,
71 *,
72 author: str = "Alice",
73 agent_id: str | None = None,
74 model_id: str | None = None,
75 branch: str = "main",
76 parent_id: str | None = None,
77 parent2_id: str | None = None,
78 committed_at: datetime.datetime | None = None,
79 ) -> str:
80 """Create and store a commit, auto-chaining to the previous on the same branch."""
81 global _commit_counter
82 _commit_counter += 1
83 content = f"c{_commit_counter}".encode()
84 obj_id = _sha(content)
85 write_object(root, obj_id, content)
86 manifest = {f"f{_commit_counter}.txt": obj_id}
87 snap_id = compute_snapshot_id(manifest)
88 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
89 ts = committed_at or datetime.datetime.now(datetime.timezone.utc)
90
91 # Auto-chain: if caller didn't provide parent_id, use the last known head.
92 effective_parent = parent_id
93 if effective_parent is None:
94 effective_parent = _branch_heads_map.get(f"{root}:{branch}")
95
96 pids = [pid for pid in (effective_parent, parent2_id) if pid is not None]
97 commit_id = compute_commit_id(
98 repo_id=_REPO_ID,
99 parent_ids=pids,
100 snapshot_id=snap_id,
101 message=f"msg {_commit_counter}",
102 committed_at_iso=ts.isoformat(),
103 author=author,
104 )
105 rec = CommitRecord(
106 commit_id=commit_id,
107 repo_id=_REPO_ID,
108 created_on_branch=branch,
109 snapshot_id=snap_id,
110 message=f"msg {_commit_counter}",
111 committed_at=ts,
112 parent_commit_id=effective_parent,
113 parent2_commit_id=parent2_id,
114 author=author,
115 agent_id=agent_id or "",
116 model_id=model_id or "",
117 )
118 write_commit(root, rec)
119 ref_dir = root / ".muse" / "refs" / "heads"
120 ref_file = ref_dir / branch
121 ref_file.parent.mkdir(parents=True, exist_ok=True)
122 ref_file.write_text(commit_id, encoding="utf-8")
123 _branch_heads_map[f"{root}:{branch}"] = commit_id
124 return commit_id
125
126
127 def _env(repo: pathlib.Path) -> Manifest:
128 return {"MUSE_REPO_ROOT": str(repo)}
129
130
131 def _invoke(args: list[str], env: Manifest) -> InvokeResult:
132 return runner.invoke(None, args, env=env)
133
134
135 class _GroupOut(TypedDict):
136 key: str
137 count: int
138 commits: list[Mapping[str, str | None]]
139
140
141 class _ShortlogOut(TypedDict):
142 repo_id: str
143 branch: str
144 groups: list[_GroupOut]
145
146
147 def _parse_json(result: InvokeResult) -> _ShortlogOut:
148 raw = json.loads(result.output.strip())
149 groups: list[_GroupOut] = [
150 _GroupOut(
151 key=g["key"],
152 count=g["count"],
153 commits=g["commits"],
154 )
155 for g in raw["groups"]
156 ]
157 return _ShortlogOut(
158 repo_id=raw["repo_id"],
159 branch=raw["branch"],
160 groups=groups,
161 )
162
163
164 # ---------------------------------------------------------------------------
165 # Unit: _branch_names — symlink guard
166 # ---------------------------------------------------------------------------
167
168
169 def test_branch_names_returns_normal_branches(tmp_path: pathlib.Path) -> None:
170 _init_repo(tmp_path)
171 _make_commit(tmp_path, branch="main")
172 _make_commit(tmp_path, branch="dev")
173 names = _branch_names(tmp_path)
174 assert "main" in names
175 assert "dev" in names
176
177
178 def test_branch_names_skips_symlinks(tmp_path: pathlib.Path) -> None:
179 _init_repo(tmp_path)
180 _make_commit(tmp_path, branch="main")
181 heads_dir = tmp_path / ".muse" / "refs" / "heads"
182 evil = heads_dir / "evil-branch"
183 try:
184 evil.symlink_to(tmp_path / "some_other_file")
185 except OSError:
186 pytest.skip("filesystem does not support symlinks")
187 names = _branch_names(tmp_path)
188 assert "evil-branch" not in names
189 assert "main" in names
190
191
192 def test_branch_names_missing_heads_dir(tmp_path: pathlib.Path) -> None:
193 _init_repo(tmp_path)
194 import shutil
195 shutil.rmtree(tmp_path / ".muse" / "refs" / "heads")
196 assert _branch_names(tmp_path) == []
197
198
199 # ---------------------------------------------------------------------------
200 # Unit: _group_key
201 # ---------------------------------------------------------------------------
202
203
204 def _make_rec(
205 *,
206 author: str = "",
207 agent_id: str = "",
208 model_id: str = "",
209 branch: str = "main",
210 ) -> CommitRecord:
211 return CommitRecord(
212 commit_id="aaa",
213 repo_id=_REPO_ID,
214 created_on_branch=branch,
215 snapshot_id="snap",
216 message="x",
217 committed_at=datetime.datetime.now(datetime.timezone.utc),
218 author=author,
219 agent_id=agent_id,
220 model_id=model_id,
221 )
222
223
224 def test_group_key_author_with_author() -> None:
225 rec = _make_rec(author="Alice")
226 assert _group_key(rec, "author") == "Alice"
227
228
229 def test_group_key_author_fallback_to_agent() -> None:
230 rec = _make_rec(agent_id="bot-1")
231 assert _group_key(rec, "author") == "bot-1 (agent)"
232
233
234 def test_group_key_author_unknown() -> None:
235 rec = _make_rec()
236 assert _group_key(rec, "author") == "(unknown)"
237
238
239 def test_group_key_agent() -> None:
240 rec = _make_rec(agent_id="gpt-agent")
241 assert _group_key(rec, "agent") == "gpt-agent"
242
243
244 def test_group_key_agent_no_agent() -> None:
245 rec = _make_rec()
246 assert _group_key(rec, "agent") == "(no agent)"
247
248
249 def test_group_key_model() -> None:
250 rec = _make_rec(model_id="gpt-4o")
251 assert _group_key(rec, "model") == "gpt-4o"
252
253
254 def test_group_key_model_no_model() -> None:
255 rec = _make_rec()
256 assert _group_key(rec, "model") == "(no model)"
257
258
259 def test_group_key_branch() -> None:
260 rec = _make_rec(branch="feat/my-thing")
261 assert _group_key(rec, "branch") == "feat/my-thing"
262
263
264 # ---------------------------------------------------------------------------
265 # Unit: _parse_date
266 # ---------------------------------------------------------------------------
267
268
269 def test_parse_date_valid() -> None:
270 dt = _parse_date("2025-03-15", "--since")
271 assert dt.year == 2025
272 assert dt.month == 3
273 assert dt.day == 15
274 assert dt.tzinfo == datetime.timezone.utc
275
276
277 def test_parse_date_invalid_exits() -> None:
278 with pytest.raises(ValueError):
279 _parse_date("not-a-date", "--since")
280
281
282 def test_parse_date_wrong_format_exits() -> None:
283 with pytest.raises(ValueError):
284 _parse_date("15/03/2025", "--since")
285
286
287 # ---------------------------------------------------------------------------
288 # Security: ANSI injection
289 # ---------------------------------------------------------------------------
290
291
292 def test_ansi_in_author_name_stripped_text(tmp_path: pathlib.Path) -> None:
293 _init_repo(tmp_path)
294 _make_commit(tmp_path, author="Evil\x1b[31mRED\x1b[0m")
295 result = _invoke(["shortlog"], _env(tmp_path))
296 assert result.exit_code == 0
297 assert "\x1b[31m" not in result.output
298
299
300 def test_ansi_in_author_name_raw_in_json(tmp_path: pathlib.Path) -> None:
301 _init_repo(tmp_path)
302 _make_commit(tmp_path, author="Evil\x1b[31mRED\x1b[0m")
303 result = _invoke(["shortlog", "--json"], _env(tmp_path))
304 assert result.exit_code == 0
305 data = _parse_json(result)
306 assert data["groups"][0]["key"] == "Evil\x1b[31mRED\x1b[0m"
307
308
309 def test_ansi_in_message_stripped_text(tmp_path: pathlib.Path) -> None:
310 _init_repo(tmp_path)
311 commit_id = _make_commit(tmp_path)
312 # Directly overwrite message in stored commit to contain ANSI.
313 from muse.core.store import read_commit
314 original = read_commit(tmp_path, commit_id)
315 assert original is not None
316 from muse.core.snapshot import compute_commit_id
317 from muse.core.store import write_commit
318 evil_msg = "fix: \x1b[1mBOLD\x1b[0m thing"
319 parent_ids = [original.parent_commit_id] if original.parent_commit_id else []
320 new_cid = compute_commit_id(
321 repo_id=original.repo_id,
322 parent_ids=parent_ids,
323 snapshot_id=original.snapshot_id,
324 message=evil_msg,
325 committed_at_iso=original.committed_at.isoformat(),
326 author=original.author,
327 )
328 patched = CommitRecord(
329 commit_id=new_cid,
330 repo_id=original.repo_id,
331 created_on_branch=original.created_on_branch,
332 snapshot_id=original.snapshot_id,
333 message=evil_msg,
334 committed_at=original.committed_at,
335 author=original.author,
336 )
337 write_commit(tmp_path, patched)
338 (tmp_path / ".muse" / "refs" / "heads" / "main").write_text(new_cid)
339 result = _invoke(["shortlog"], _env(tmp_path))
340 assert "\x1b[1m" not in result.output
341
342
343 # ---------------------------------------------------------------------------
344 # Error routing: all user errors go to stderr
345 # ---------------------------------------------------------------------------
346
347
348 def test_since_invalid_format_stderr(tmp_path: pathlib.Path) -> None:
349 _init_repo(tmp_path)
350 _make_commit(tmp_path)
351 result = _invoke(["shortlog", "--since", "01-01-2025"], _env(tmp_path))
352 assert result.exit_code != 0
353
354
355 def test_until_invalid_format_stderr(tmp_path: pathlib.Path) -> None:
356 _init_repo(tmp_path)
357 _make_commit(tmp_path)
358 result = _invoke(["shortlog", "--until", "not-a-date"], _env(tmp_path))
359 assert result.exit_code != 0
360
361
362 # ---------------------------------------------------------------------------
363 # JSON schema: _ShortlogJson
364 # ---------------------------------------------------------------------------
365
366
367 def test_json_schema_empty_repo(tmp_path: pathlib.Path) -> None:
368 _init_repo(tmp_path)
369 result = _invoke(["shortlog", "--json"], _env(tmp_path))
370 assert result.exit_code == 0
371 data = _parse_json(result)
372 assert data["repo_id"] == _REPO_ID
373 assert data["branch"] == "main"
374 assert data["groups"] == []
375
376
377 def test_json_schema_all_fields_present(tmp_path: pathlib.Path) -> None:
378 _init_repo(tmp_path)
379 _make_commit(tmp_path, author="Alice", agent_id="bot-1", model_id="gpt-4o")
380 result = _invoke(["shortlog", "--json"], _env(tmp_path))
381 assert result.exit_code == 0
382 data = _parse_json(result)
383 assert data["repo_id"] == _REPO_ID
384 assert data["branch"] == "main"
385 grp = data["groups"][0]
386 assert grp["key"] == "Alice"
387 assert grp["count"] == 1
388 commit_entry = grp["commits"][0]
389 assert "commit_id" in commit_entry
390 assert "message" in commit_entry
391 assert "committed_at" in commit_entry
392 assert "author" in commit_entry
393 assert "agent_id" in commit_entry
394 assert "model_id" in commit_entry
395
396
397 def test_json_schema_repo_id_and_branch_in_output(tmp_path: pathlib.Path) -> None:
398 _init_repo(tmp_path)
399 _make_commit(tmp_path, branch="main")
400 result = _invoke(["shortlog", "--json"], _env(tmp_path))
401 assert result.exit_code == 0
402 data = _parse_json(result)
403 assert data["repo_id"] == _REPO_ID
404 assert data["branch"] == "main"
405
406
407 def test_json_schema_all_branches_label(tmp_path: pathlib.Path) -> None:
408 _init_repo(tmp_path)
409 _make_commit(tmp_path, branch="main")
410 result = _invoke(["shortlog", "--all", "--json"], _env(tmp_path))
411 assert result.exit_code == 0
412 data = _parse_json(result)
413 assert data["branch"] == "__all__"
414
415
416 def test_json_agent_id_and_model_id_present(tmp_path: pathlib.Path) -> None:
417 _init_repo(tmp_path)
418 _make_commit(tmp_path, agent_id="agent-007", model_id="claude-3")
419 result = _invoke(["shortlog", "--json"], _env(tmp_path))
420 assert result.exit_code == 0
421 data = _parse_json(result)
422 entry = data["groups"][0]["commits"][0]
423 assert entry["agent_id"] == "agent-007"
424 assert entry["model_id"] == "claude-3"
425
426
427 # ---------------------------------------------------------------------------
428 # New flag: --group-by
429 # ---------------------------------------------------------------------------
430
431
432 def test_group_by_agent(tmp_path: pathlib.Path) -> None:
433 _init_repo(tmp_path)
434 _make_commit(tmp_path, author="Alice", agent_id="bot-1")
435 _make_commit(tmp_path, author="Bob", agent_id="bot-2")
436 _make_commit(tmp_path, author="Alice", agent_id="bot-1")
437 result = _invoke(["shortlog", "--group-by", "agent", "--json"], _env(tmp_path))
438 assert result.exit_code == 0
439 data = _parse_json(result)
440 keys = {g["key"] for g in data["groups"]}
441 assert "bot-1" in keys
442 assert "bot-2" in keys
443
444
445 def test_group_by_model(tmp_path: pathlib.Path) -> None:
446 _init_repo(tmp_path)
447 _make_commit(tmp_path, model_id="gpt-4o")
448 _make_commit(tmp_path, model_id="claude-3")
449 _make_commit(tmp_path, model_id="gpt-4o")
450 result = _invoke(["shortlog", "--group-by", "model", "--json"], _env(tmp_path))
451 assert result.exit_code == 0
452 data = _parse_json(result)
453 keys = {g["key"] for g in data["groups"]}
454 assert "gpt-4o" in keys
455 assert "claude-3" in keys
456 gpt_count = next(g["count"] for g in data["groups"] if g["key"] == "gpt-4o")
457 assert gpt_count == 2
458
459
460 def test_group_by_branch(tmp_path: pathlib.Path) -> None:
461 _init_repo(tmp_path)
462 _make_commit(tmp_path, branch="main")
463 _make_commit(tmp_path, branch="dev")
464 _make_commit(tmp_path, branch="main")
465 result = _invoke(
466 ["shortlog", "--all", "--group-by", "branch", "--json"], _env(tmp_path)
467 )
468 assert result.exit_code == 0
469 data = _parse_json(result)
470 keys = {g["key"] for g in data["groups"]}
471 assert "main" in keys
472 assert "dev" in keys
473
474
475 def test_group_by_invalid_choice(tmp_path: pathlib.Path) -> None:
476 _init_repo(tmp_path)
477 result = _invoke(["shortlog", "--group-by", "badfield"], _env(tmp_path))
478 assert result.exit_code != 0
479
480
481 # ---------------------------------------------------------------------------
482 # New flag: --summary
483 # ---------------------------------------------------------------------------
484
485
486 def test_summary_suppresses_messages(tmp_path: pathlib.Path) -> None:
487 _init_repo(tmp_path)
488 _make_commit(tmp_path, author="Alice")
489 _make_commit(tmp_path, author="Alice")
490 result = _invoke(["shortlog", "--summary"], _env(tmp_path))
491 assert result.exit_code == 0
492 # Author line should still appear.
493 assert "Alice" in result.output
494 # Individual commit messages should not appear (they start with spaces).
495 assert "msg" not in result.output
496
497
498 def test_summary_with_json_still_includes_commits(tmp_path: pathlib.Path) -> None:
499 """--summary only suppresses messages in text mode; JSON always includes them."""
500 _init_repo(tmp_path)
501 _make_commit(tmp_path, author="Alice")
502 result = _invoke(["shortlog", "--summary", "--json"], _env(tmp_path))
503 assert result.exit_code == 0
504 data = _parse_json(result)
505 assert len(data["groups"][0]["commits"]) >= 1
506
507
508 # ---------------------------------------------------------------------------
509 # New flag: --no-merges
510 # ---------------------------------------------------------------------------
511
512
513 def test_no_merges_excludes_merge_commits(tmp_path: pathlib.Path) -> None:
514 """get_commits_for_branch follows first-parent only.
515
516 Chain: c1 → c2 → c3(merge, parent2=c1) → c4
517 First-parent walk from c4 returns [c4, c3, c2, c1].
518 With --no-merges, c3 is excluded → 3 commits remain.
519 """
520 _init_repo(tmp_path)
521 c1 = _make_commit(tmp_path, author="Alice")
522 c2 = _make_commit(tmp_path, author="Bob") # chains to c1
523 # Merge commit: auto-chains first-parent to c2; parent2 points to c1.
524 _make_commit(tmp_path, author="Alice", parent2_id=c1) # chains to c2
525 _make_commit(tmp_path, author="Bob") # chains to merge
526 result = _invoke(["shortlog", "--no-merges", "--json"], _env(tmp_path))
527 assert result.exit_code == 0
528 data = _parse_json(result)
529 total = sum(g["count"] for g in data["groups"])
530 assert total == 3 # c1, c2, c4 — c3 (merge) excluded
531
532
533 def test_no_merges_with_all_non_merges(tmp_path: pathlib.Path) -> None:
534 _init_repo(tmp_path)
535 for _ in range(5):
536 _make_commit(tmp_path, author="Alice")
537 result = _invoke(["shortlog", "--no-merges", "--json"], _env(tmp_path))
538 assert result.exit_code == 0
539 data = _parse_json(result)
540 assert sum(g["count"] for g in data["groups"]) == 5
541
542
543 # ---------------------------------------------------------------------------
544 # New flags: --since / --until
545 # ---------------------------------------------------------------------------
546
547
548 def test_since_filters_old_commits(tmp_path: pathlib.Path) -> None:
549 _init_repo(tmp_path)
550 old = datetime.datetime(2020, 1, 1, tzinfo=datetime.timezone.utc)
551 new = datetime.datetime(2025, 6, 1, tzinfo=datetime.timezone.utc)
552 _make_commit(tmp_path, author="Old", committed_at=old)
553 _make_commit(tmp_path, author="New", committed_at=new)
554 result = _invoke(["shortlog", "--since", "2025-01-01", "--json"], _env(tmp_path))
555 assert result.exit_code == 0
556 data = _parse_json(result)
557 keys = {g["key"] for g in data["groups"]}
558 assert "New" in keys
559 assert "Old" not in keys
560
561
562 def test_until_filters_future_commits(tmp_path: pathlib.Path) -> None:
563 _init_repo(tmp_path)
564 old = datetime.datetime(2020, 1, 1, tzinfo=datetime.timezone.utc)
565 new = datetime.datetime(2025, 6, 1, tzinfo=datetime.timezone.utc)
566 _make_commit(tmp_path, author="Old", committed_at=old)
567 _make_commit(tmp_path, author="New", committed_at=new)
568 result = _invoke(["shortlog", "--until", "2022-12-31", "--json"], _env(tmp_path))
569 assert result.exit_code == 0
570 data = _parse_json(result)
571 keys = {g["key"] for g in data["groups"]}
572 assert "Old" in keys
573 assert "New" not in keys
574
575
576 def test_since_and_until_window(tmp_path: pathlib.Path) -> None:
577 _init_repo(tmp_path)
578 dates = [
579 datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc),
580 datetime.datetime(2025, 3, 15, tzinfo=datetime.timezone.utc),
581 datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
582 ]
583 authors = ["Before", "Inside", "After"]
584 for a, d in zip(authors, dates):
585 _make_commit(tmp_path, author=a, committed_at=d)
586 result = _invoke(
587 ["shortlog", "--since", "2025-01-01", "--until", "2025-12-31", "--json"],
588 _env(tmp_path),
589 )
590 assert result.exit_code == 0
591 data = _parse_json(result)
592 keys = {g["key"] for g in data["groups"]}
593 assert "Inside" in keys
594 assert "Before" not in keys
595 assert "After" not in keys
596
597
598 def test_since_no_results_returns_empty_json(tmp_path: pathlib.Path) -> None:
599 _init_repo(tmp_path)
600 _make_commit(
601 tmp_path,
602 author="Old",
603 committed_at=datetime.datetime(2020, 1, 1, tzinfo=datetime.timezone.utc),
604 )
605 result = _invoke(["shortlog", "--since", "2030-01-01", "--json"], _env(tmp_path))
606 assert result.exit_code == 0
607 data = _parse_json(result)
608 assert data["groups"] == []
609
610
611 # ---------------------------------------------------------------------------
612 # Integration
613 # ---------------------------------------------------------------------------
614
615
616 def test_integration_all_branches_dedup(tmp_path: pathlib.Path) -> None:
617 """A commit reachable from two branches should count once."""
618 _init_repo(tmp_path)
619 shared = _make_commit(tmp_path, author="Alice", branch="main")
620 # Create dev branch pointing at same commit (by writing the ref file).
621 dev_ref = tmp_path / ".muse" / "refs" / "heads" / "dev"
622 dev_ref.write_text(shared, encoding="utf-8")
623 result = _invoke(["shortlog", "--all", "--json"], _env(tmp_path))
624 assert result.exit_code == 0
625 data = _parse_json(result)
626 total = sum(g["count"] for g in data["groups"])
627 assert total == 1 # deduplicated
628
629
630 def test_integration_limit_early_exit(tmp_path: pathlib.Path) -> None:
631 _init_repo(tmp_path)
632 for i in range(50):
633 _make_commit(tmp_path, author=f"Author{i % 5}")
634 result = _invoke(["shortlog", "--limit", "10", "--json"], _env(tmp_path))
635 assert result.exit_code == 0
636 data = _parse_json(result)
637 total = sum(g["count"] for g in data["groups"])
638 assert total <= 10
639
640
641 def test_integration_numbered_combined_with_since(tmp_path: pathlib.Path) -> None:
642 _init_repo(tmp_path)
643 old = datetime.datetime(2020, 1, 1, tzinfo=datetime.timezone.utc)
644 new = datetime.datetime(2025, 6, 1, tzinfo=datetime.timezone.utc)
645 for _ in range(3):
646 _make_commit(tmp_path, author="Prolific", committed_at=new)
647 _make_commit(tmp_path, author="Old", committed_at=old)
648 result = _invoke(
649 ["shortlog", "--since", "2025-01-01", "--numbered", "--json"],
650 _env(tmp_path),
651 )
652 assert result.exit_code == 0
653 data = _parse_json(result)
654 assert data["groups"][0]["key"] == "Prolific"
655 assert "Old" not in {g["key"] for g in data["groups"]}
656
657
658 # ---------------------------------------------------------------------------
659 # E2E: help output
660 # ---------------------------------------------------------------------------
661
662
663 def test_help_shows_new_flags() -> None:
664 result = _invoke(["shortlog", "--help"], {})
665 assert result.exit_code == 0
666 for flag in ("--group-by", "--summary", "--no-merges", "--since", "--until", "--json"):
667 assert flag in result.output, f"Missing flag: {flag}"
668
669
670 def test_help_mentions_group_by_choices() -> None:
671 result = _invoke(["shortlog", "--help"], {})
672 for choice in ("author", "agent", "model", "branch"):
673 assert choice in result.output
674
675
676 # ---------------------------------------------------------------------------
677 # Stress: 500 commits × 5 authors
678 # ---------------------------------------------------------------------------
679
680
681 def test_stress_500_commits(tmp_path: pathlib.Path) -> None:
682 _init_repo(tmp_path)
683 authors = ["Amy", "Ben", "Cleo", "Dan", "Eva"]
684 for i in range(500):
685 _make_commit(tmp_path, author=authors[i % 5])
686 result = _invoke(["shortlog", "--json"], _env(tmp_path))
687 assert result.exit_code == 0
688 data = _parse_json(result)
689 total = sum(g["count"] for g in data["groups"])
690 assert total == 500
691 assert len(data["groups"]) == 5
692
693
694 def test_stress_500_commits_numbered(tmp_path: pathlib.Path) -> None:
695 _init_repo(tmp_path)
696 # Give Alice 300, Bob 200.
697 for _ in range(300):
698 _make_commit(tmp_path, author="Alice")
699 for _ in range(200):
700 _make_commit(tmp_path, author="Bob")
701 result = _invoke(["shortlog", "--numbered", "--json"], _env(tmp_path))
702 assert result.exit_code == 0
703 data = _parse_json(result)
704 assert data["groups"][0]["key"] == "Alice"
705 assert data["groups"][0]["count"] == 300
706
707
708 # ---------------------------------------------------------------------------
709 # JSON schema — duration_ms + exit_code + truncated on every output path
710 # ---------------------------------------------------------------------------
711
712
713 class TestJsonSchema:
714 """Every --json response must carry duration_ms, exit_code, and truncated."""
715
716 def _assert_schema(self, d: Mapping[str, object], *, exit_code: int = 0) -> None:
717 assert "duration_ms" in d, f"duration_ms missing: {d}"
718 assert isinstance(d["duration_ms"], (int, float))
719 assert d["duration_ms"] >= 0
720 assert "exit_code" in d, f"exit_code missing: {d}"
721 assert d["exit_code"] == exit_code
722 assert "truncated" in d, f"truncated missing: {d}"
723
724 def test_normal_output_has_schema(self, tmp_path: pathlib.Path) -> None:
725 _init_repo(tmp_path)
726 _make_commit(tmp_path, author="Alice")
727 result = _invoke(["shortlog", "--json"], _env(tmp_path))
728 assert result.exit_code == 0
729 self._assert_schema(json.loads(result.output))
730
731 def test_empty_repo_json_has_schema(self, tmp_path: pathlib.Path) -> None:
732 _init_repo(tmp_path)
733 result = _invoke(["shortlog", "--json"], _env(tmp_path))
734 assert result.exit_code == 0
735 self._assert_schema(json.loads(result.output))
736
737 def test_all_branches_json_has_schema(self, tmp_path: pathlib.Path) -> None:
738 _init_repo(tmp_path)
739 _make_commit(tmp_path, branch="main")
740 result = _invoke(["shortlog", "--all", "--json"], _env(tmp_path))
741 assert result.exit_code == 0
742 self._assert_schema(json.loads(result.output))
743
744 def test_numbered_json_has_schema(self, tmp_path: pathlib.Path) -> None:
745 _init_repo(tmp_path)
746 _make_commit(tmp_path, author="Alice")
747 _make_commit(tmp_path, author="Bob")
748 result = _invoke(["shortlog", "--numbered", "--json"], _env(tmp_path))
749 assert result.exit_code == 0
750 self._assert_schema(json.loads(result.output))
751
752 def test_since_filtered_empty_has_schema(self, tmp_path: pathlib.Path) -> None:
753 """_emit_empty path (after filtering) must also carry the schema."""
754 _init_repo(tmp_path)
755 _make_commit(
756 tmp_path, author="Old",
757 committed_at=datetime.datetime(2020, 1, 1, tzinfo=datetime.timezone.utc),
758 )
759 result = _invoke(["shortlog", "--since", "2030-01-01", "--json"], _env(tmp_path))
760 assert result.exit_code == 0
761 self._assert_schema(json.loads(result.output))
762
763 def test_exit_code_zero_on_success(self, tmp_path: pathlib.Path) -> None:
764 _init_repo(tmp_path)
765 _make_commit(tmp_path)
766 result = _invoke(["shortlog", "--json"], _env(tmp_path))
767 d = json.loads(result.output)
768 assert d["exit_code"] == 0
769
770
771 # ---------------------------------------------------------------------------
772 # truncated flag — set when --limit caps the result
773 # ---------------------------------------------------------------------------
774
775
776 class TestTruncated:
777 """truncated:true when --limit hit; false otherwise."""
778
779 def test_truncated_true_when_limit_hit(self, tmp_path: pathlib.Path) -> None:
780 _init_repo(tmp_path)
781 for _ in range(10):
782 _make_commit(tmp_path, author="Alice")
783 result = _invoke(["shortlog", "--limit", "3", "--json"], _env(tmp_path))
784 assert result.exit_code == 0
785 d = json.loads(result.output)
786 assert d["truncated"] is True
787
788 def test_truncated_false_when_under_limit(self, tmp_path: pathlib.Path) -> None:
789 _init_repo(tmp_path)
790 for _ in range(5):
791 _make_commit(tmp_path, author="Alice")
792 result = _invoke(["shortlog", "--limit", "10", "--json"], _env(tmp_path))
793 assert result.exit_code == 0
794 d = json.loads(result.output)
795 assert d["truncated"] is False
796
797 def test_truncated_false_when_no_limit(self, tmp_path: pathlib.Path) -> None:
798 _init_repo(tmp_path)
799 for _ in range(5):
800 _make_commit(tmp_path, author="Alice")
801 result = _invoke(["shortlog", "--json"], _env(tmp_path))
802 assert result.exit_code == 0
803 d = json.loads(result.output)
804 assert d["truncated"] is False
805
806 def test_truncated_false_on_empty_repo(self, tmp_path: pathlib.Path) -> None:
807 _init_repo(tmp_path)
808 result = _invoke(["shortlog", "--json"], _env(tmp_path))
809 assert result.exit_code == 0
810 d = json.loads(result.output)
811 assert d["truncated"] is False
812
813
814 # ---------------------------------------------------------------------------
815 # Error JSON — date parse errors emit structured JSON to stdout with --json
816 # ---------------------------------------------------------------------------
817
818
819 class TestErrorJson:
820 """--since / --until bad dates must emit JSON to stdout when --json is set."""
821
822 def _assert_error(self, result: InvokeResult) -> Mapping[str, object]:
823 assert result.exit_code != 0
824 d = json.loads(result.output)
825 assert "error" in d
826 assert "duration_ms" in d
827 assert "exit_code" in d
828 assert d["exit_code"] != 0
829 return d
830
831 def test_since_bad_date_json_error(self, tmp_path: pathlib.Path) -> None:
832 _init_repo(tmp_path)
833 _make_commit(tmp_path)
834 result = _invoke(["shortlog", "--json", "--since", "not-a-date"], _env(tmp_path))
835 self._assert_error(result)
836
837 def test_until_bad_date_json_error(self, tmp_path: pathlib.Path) -> None:
838 _init_repo(tmp_path)
839 _make_commit(tmp_path)
840 result = _invoke(["shortlog", "--json", "--until", "01/01/2025"], _env(tmp_path))
841 self._assert_error(result)
842
843 def test_date_error_has_message(self, tmp_path: pathlib.Path) -> None:
844 _init_repo(tmp_path)
845 result = _invoke(["shortlog", "--json", "--since", "garbage"], _env(tmp_path))
846 d = self._assert_error(result)
847 assert isinstance(d["message"], str) and len(d["message"]) > 0
848
849
850 # ---------------------------------------------------------------------------
851 # _parse_date refactor — now raises ValueError, not SystemExit
852 # ---------------------------------------------------------------------------
853
854
855 class TestParseDateRefactor:
856 """_parse_date is a pure parser; it raises ValueError, not SystemExit."""
857
858 def test_invalid_date_raises_value_error(self) -> None:
859 with pytest.raises(ValueError):
860 _parse_date("not-a-date", "--since")
861
862 def test_wrong_format_raises_value_error(self) -> None:
863 with pytest.raises(ValueError):
864 _parse_date("15/03/2025", "--since")
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 137 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago