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