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