wizz-method 1.14.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/package.json +3 -2
  2. package/skills-registry.yaml +4 -0
  3. package/src/bmm-skills/3-solutioning/wizz-architecture/scripts/__pycache__/lint_spine.cpython-313.pyc +0 -0
  4. package/src/bmm-skills/3-solutioning/wizz-architecture/scripts/tests/__pycache__/test_lint_spine.cpython-313-pytest-9.1.1.pyc +0 -0
  5. package/src/bmm-skills/4-implementation/wizz-code-review/SKILL.md +3 -5
  6. package/src/bmm-skills/4-implementation/wizz-code-review/customize.toml +71 -0
  7. package/src/bmm-skills/4-implementation/wizz-code-review/references/claims-check.md +14 -0
  8. package/src/bmm-skills/4-implementation/wizz-code-review/references/deletion-check.md +14 -0
  9. package/src/bmm-skills/4-implementation/wizz-code-review/review-prompts/edge-case-hunter.md +110 -0
  10. package/src/bmm-skills/4-implementation/wizz-code-review/review-prompts/verification-gap.md +113 -0
  11. package/src/bmm-skills/4-implementation/wizz-code-review/steps/step-01-gather-context.md +32 -23
  12. package/src/bmm-skills/4-implementation/wizz-code-review/steps/step-02-review.md +10 -13
  13. package/src/bmm-skills/4-implementation/wizz-code-review/steps/step-03-triage.md +16 -22
  14. package/src/bmm-skills/4-implementation/wizz-code-review/steps/step-04-present.md +4 -2
  15. package/src/bmm-skills/4-implementation/wizz-quick-dev/customize.toml +76 -0
  16. package/src/bmm-skills/4-implementation/wizz-quick-dev/references/claims-check.md +14 -0
  17. package/src/bmm-skills/4-implementation/wizz-quick-dev/references/deletion-check.md +14 -0
  18. package/src/bmm-skills/4-implementation/wizz-quick-dev/review-prompts/edge-case-hunter.md +110 -0
  19. package/src/bmm-skills/4-implementation/wizz-quick-dev/review-prompts/verification-gap.md +113 -0
  20. package/src/bmm-skills/4-implementation/wizz-quick-dev/step-04-review.md +38 -16
  21. package/src/bmm-skills/4-implementation/wizz-retrospective/SKILL.md +56 -1488
  22. package/src/bmm-skills/4-implementation/wizz-retrospective/customize.toml +3 -5
  23. package/src/bmm-skills/4-implementation/wizz-retrospective/references/acceptance-verdict.md +55 -0
  24. package/src/bmm-skills/4-implementation/wizz-retrospective/references/aggregate-views.md +17 -0
  25. package/src/bmm-skills/4-implementation/wizz-retrospective/references/evidence-gathering.md +30 -0
  26. package/src/bmm-skills/4-implementation/wizz-retrospective/references/retro-document.md +84 -0
  27. package/src/bmm-skills/4-implementation/wizz-retrospective/references/team-discussion.md +22 -0
  28. package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/__pycache__/sprint_status.cpython-313.pyc +0 -0
  29. package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/git_evidence.py +304 -0
  30. package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/sprint_status.py +746 -0
  31. package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/tests/__pycache__/test_git_evidence.cpython-313-pytest-9.1.1.pyc +0 -0
  32. package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/tests/__pycache__/test_sprint_status.cpython-313-pytest-9.1.1.pyc +0 -0
  33. package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/tests/fixtures/sprint-status-template.yaml +71 -0
  34. package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/tests/test_git_evidence.py +750 -0
  35. package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/tests/test_sprint_status.py +1579 -0
  36. package/src/bmm-skills/4-implementation/wizz-sprint-planning/SKILL.md +39 -296
  37. package/src/bmm-skills/4-implementation/wizz-sprint-planning/customize.toml +1 -3
  38. package/src/bmm-skills/4-implementation/wizz-sprint-planning/references/fix-sprint-status.md +30 -0
  39. package/src/bmm-skills/4-implementation/wizz-sprint-planning/references/generate-tracking.md +25 -0
  40. package/src/bmm-skills/4-implementation/wizz-sprint-planning/references/readiness-gate.md +20 -0
  41. package/src/bmm-skills/4-implementation/wizz-sprint-planning/references/status-view.md +14 -0
  42. package/src/bmm-skills/4-implementation/wizz-sprint-planning/references/validate.md +10 -0
  43. package/src/bmm-skills/4-implementation/wizz-sprint-planning/scripts/__pycache__/sprint_plan.cpython-313.pyc +0 -0
  44. package/src/bmm-skills/4-implementation/wizz-sprint-planning/scripts/sprint_plan.py +697 -0
  45. package/src/bmm-skills/4-implementation/wizz-sprint-planning/scripts/tests/__pycache__/test_sprint_plan.cpython-313-pytest-9.1.1.pyc +0 -0
  46. package/src/bmm-skills/4-implementation/wizz-sprint-planning/scripts/tests/test_sprint_plan.py +524 -0
  47. package/src/bmm-skills/4-implementation/wizz-sprint-planning/sprint-status-template.yaml +9 -7
  48. package/src/bmm-skills/module-help.csv +3 -3
  49. package/src/core-skills/_shared/handoff-protocol.md +7 -8
  50. package/src/core-skills/module-help.csv +1 -0
  51. package/src/core-skills/wizz-advanced-elicitation/SKILL.md +36 -114
  52. package/src/core-skills/wizz-advanced-elicitation/{methods.csv → assets/methods.csv} +29 -27
  53. package/src/core-skills/wizz-advanced-elicitation/customize.toml +54 -0
  54. package/src/core-skills/wizz-advanced-elicitation/scripts/__pycache__/pick_methods.cpython-313.pyc +0 -0
  55. package/src/core-skills/wizz-advanced-elicitation/scripts/pick_methods.py +233 -0
  56. package/src/core-skills/wizz-advanced-elicitation/scripts/tests/__pycache__/test_pick_methods.cpython-313-pytest-9.1.1.pyc +0 -0
  57. package/src/core-skills/wizz-advanced-elicitation/scripts/tests/test_pick_methods.py +228 -0
  58. package/src/core-skills/wizz-brainstorming/SKILL.md +2 -2
  59. package/src/core-skills/wizz-brainstorming/assets/brain-selector.html +2 -0
  60. package/src/core-skills/wizz-brainstorming/references/converge.md +1 -1
  61. package/src/core-skills/wizz-brainstorming/references/finalize.md +1 -1
  62. package/src/core-skills/wizz-brainstorming/references/headless.md +1 -1
  63. package/src/core-skills/wizz-brainstorming/references/mode-autonomous.md +1 -1
  64. package/src/core-skills/wizz-brainstorming/scripts/__pycache__/brain.cpython-313.pyc +0 -0
  65. package/src/core-skills/wizz-brainstorming/scripts/brain.py +36 -6
  66. package/src/core-skills/wizz-brainstorming/scripts/tests/__pycache__/test_brain.cpython-313-pytest-9.1.1.pyc +0 -0
  67. package/src/core-skills/wizz-brainstorming/scripts/tests/__pycache__/test_brain.cpython-314.pyc +0 -0
  68. package/src/core-skills/wizz-brainstorming/scripts/tests/test_brain.py +24 -2
  69. package/src/core-skills/wizz-customize/scripts/__pycache__/list_customizable_skills.cpython-313.pyc +0 -0
  70. package/src/core-skills/wizz-customize/scripts/tests/__pycache__/test_list_customizable_skills.cpython-313-pytest-9.1.1.pyc +0 -0
  71. package/src/core-skills/wizz-forge-idea/SKILL.md +107 -0
  72. package/src/core-skills/wizz-forge-idea/customize.toml +41 -0
  73. package/src/core-skills/wizz-forge-idea/scripts/__pycache__/resolve_personas.cpython-313.pyc +0 -0
  74. package/src/core-skills/wizz-forge-idea/scripts/__pycache__/resolve_personas.cpython-314.pyc +0 -0
  75. package/src/core-skills/wizz-forge-idea/scripts/resolve_personas.py +275 -0
  76. package/src/core-skills/wizz-forge-idea/scripts/tests/__pycache__/test_resolve_personas.cpython-313-pytest-9.1.1.pyc +0 -0
  77. package/src/core-skills/wizz-forge-idea/scripts/tests/__pycache__/test_resolve_personas.cpython-314.pyc +0 -0
  78. package/src/core-skills/wizz-forge-idea/scripts/tests/test_resolve_personas.py +138 -0
  79. package/src/core-skills/wizz-party-mode/SKILL.md +37 -54
  80. package/src/core-skills/wizz-party-mode/customize.toml +61 -2
  81. package/src/core-skills/wizz-party-mode/references/create-party.md +8 -3
  82. package/src/core-skills/wizz-party-mode/references/mode-agent-team.md +3 -1
  83. package/src/core-skills/wizz-party-mode/references/mode-subagent.md +16 -4
  84. package/src/core-skills/wizz-party-mode/references/party-memory.md +51 -0
  85. package/src/core-skills/wizz-party-mode/scripts/__pycache__/resolve_party.cpython-313.pyc +0 -0
  86. package/src/core-skills/wizz-party-mode/scripts/__pycache__/resolve_party.cpython-314.pyc +0 -0
  87. package/src/core-skills/wizz-party-mode/scripts/resolve_party.py +22 -7
  88. package/src/core-skills/wizz-party-mode/scripts/tests/__pycache__/test_resolve_party.cpython-313-pytest-9.1.1.pyc +0 -0
  89. package/src/core-skills/wizz-party-mode/scripts/tests/__pycache__/test_resolve_party.cpython-314.pyc +0 -0
  90. package/src/core-skills/wizz-party-mode/scripts/tests/{test-resolve_party.py → test_resolve_party.py} +8 -0
  91. package/src/core-skills/wizz-review-edge-case-hunter/SKILL.md +17 -3
  92. package/src/modules/wizz/README.md +1 -1
  93. package/src/modules/wizz/_shared/model-ladder.md +22 -0
  94. package/src/modules/wizz/_shared/token-economy.md +6 -2
  95. package/src/modules/wizz/agents/wizz-ads/customize.toml +1 -1
  96. package/src/modules/wizz/agents/wizz-copy/customize.toml +1 -1
  97. package/src/modules/wizz/agents/wizz-designer/customize.toml +1 -1
  98. package/src/modules/wizz/agents/wizz-growth/customize.toml +11 -1
  99. package/src/modules/wizz/agents/wizz-maestro/customize.toml +1 -1
  100. package/src/modules/wizz/agents/wizz-memoria/customize.toml +1 -1
  101. package/src/modules/wizz/agents/wizz-qa/customize.toml +1 -1
  102. package/src/modules/wizz/agents/wizz-seo/customize.toml +1 -1
  103. package/src/modules/wizz/agents/wizz-social/customize.toml +1 -1
  104. package/src/modules/wizz/overrides/wizz-agent-analyst.toml +1 -1
  105. package/src/modules/wizz/overrides/wizz-agent-architect.toml +1 -1
  106. package/src/modules/wizz/overrides/wizz-agent-dev.toml +1 -1
  107. package/src/modules/wizz/overrides/wizz-agent-pm.toml +1 -1
  108. package/src/modules/wizz/overrides/wizz-agent-tech-writer.toml +1 -1
  109. package/src/modules/wizz/overrides/wizz-agent-ux-designer.toml +1 -1
  110. package/src/modules/wizz/subagents/codex/wizz-exec-haiku.toml +16 -0
  111. package/src/modules/wizz/subagents/codex/wizz-exec-opus.toml +18 -0
  112. package/src/modules/wizz/subagents/codex/wizz-exec-review.toml +17 -0
  113. package/src/modules/wizz/subagents/codex/wizz-exec-sonnet.toml +17 -0
  114. package/src/modules/wizz/subagents/gemini/wizz-exec-haiku.md +15 -0
  115. package/src/modules/wizz/subagents/gemini/wizz-exec-opus.md +17 -0
  116. package/src/modules/wizz/subagents/gemini/wizz-exec-review.md +15 -0
  117. package/src/modules/wizz/subagents/gemini/wizz-exec-sonnet.md +16 -0
  118. package/src/modules/wizz/subagents/opencode/wizz-exec-haiku.md +14 -0
  119. package/src/modules/wizz/subagents/opencode/wizz-exec-opus.md +16 -0
  120. package/src/modules/wizz/subagents/opencode/wizz-exec-review.md +16 -0
  121. package/src/modules/wizz/subagents/opencode/wizz-exec-sonnet.md +15 -0
  122. package/src/modules/wizz/subagents/wizz-exec-opus.md +16 -0
  123. package/src/modules/wizz/subagents/wizz-exec-review.md +15 -0
  124. package/src/scripts/__pycache__/memlog.cpython-313.pyc +0 -0
  125. package/src/scripts/tests/__pycache__/test_memlog.cpython-313-pytest-9.1.1.pyc +0 -0
  126. package/src/scripts/tests/__pycache__/test_resolve_customization.cpython-313-pytest-9.1.1.pyc +0 -0
  127. package/src/skills-lib/pesquisa-de-publico-do-piva/README.md +83 -0
  128. package/src/skills-lib/pesquisa-de-publico-do-piva/SKILL.md +65 -0
  129. package/src/skills-lib/pesquisa-de-publico-do-piva/assets/template.html +414 -0
  130. package/src/skills-lib/pesquisa-de-publico-do-piva/prompts.md +131 -0
  131. package/src/skills-lib/wizz-offer-forge/README.md +13 -0
  132. package/src/skills-lib/wizz-offer-forge/SKILL.md +74 -0
  133. package/src/skills-lib/wizz-offer-forge/references/empilhamento-valor.md +25 -0
  134. package/src/skills-lib/wizz-offer-forge/references/garantia-reversa.md +22 -0
  135. package/src/skills-lib/wizz-offer-forge/references/mecanismo-unico.md +28 -0
  136. package/src/skills-lib/wizz-offer-forge/references/ponte-trafego.md +21 -0
  137. package/src/skills-lib/wizz-router/references/routing-table-flat.md +2 -0
  138. package/tools/installer/core/installer.js +57 -0
  139. package/tools/installer/ide/_config-driven.js +44 -30
  140. package/tools/installer/ide/platform-codes.yaml +42 -3
  141. package/tools/installer/modules/external-manager.js +140 -2
  142. package/tools/installer/modules/official-modules.js +68 -21
  143. package/tools/installer/prompts.js +45 -103
  144. package/tools/installer/ui.js +64 -7
  145. package/wizz-modules.yaml +65 -11
  146. package/src/bmm-skills/4-implementation/wizz-sprint-planning/checklist.md +0 -34
  147. package/src/core-skills/wizz-brainstorming/scripts/memlog.py +0 -202
  148. package/src/core-skills/wizz-brainstorming/scripts/tests/test_memlog.py +0 -265
@@ -0,0 +1,750 @@
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = ["pytest>=8.0"]
4
+ # ///
5
+ """Tests for git_evidence.py — measurement over a real temp git repo.
6
+
7
+ Run: python3 scripts/tests/test_git_evidence.py
8
+ or: python3 --with pytest -m pytest scripts/tests/test_git_evidence.py
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ import unicodedata
17
+ from pathlib import Path
18
+
19
+ import pytest
20
+
21
+ SCRIPT = Path(__file__).resolve().parents[1] / "git_evidence.py"
22
+
23
+ # The fixture commit identity, shared by both git helpers below.
24
+ _IDENT = {
25
+ "GIT_AUTHOR_NAME": "T",
26
+ "GIT_AUTHOR_EMAIL": "t@t",
27
+ "GIT_COMMITTER_NAME": "T",
28
+ "GIT_COMMITTER_EMAIL": "t@t",
29
+ }
30
+
31
+
32
+ def _git_env(repo):
33
+ """The environment every fixture git runs under, layered outward.
34
+
35
+ Inherit the real environment (PATH above all: git lives in /opt/homebrew,
36
+ /usr/local, or a nix store as readily as /usr/bin, and an env holding only
37
+ GIT_* vars sends execvp to os.defpath), then strip every ambient GIT_* var
38
+ -- GIT_DIR, GIT_WORK_TREE and GIT_CONFIG_COUNT would each silently redirect
39
+ or reconfigure the fixture -- and pin identity plus every source git reads
40
+ for settings, so nothing on the developer's machine can reach the fixture:
41
+
42
+ - gitconfig (commit.gpgsign, core.autocrlf, core.hooksPath,
43
+ init.defaultBranch). GIT_CONFIG_NOSYSTEM/GIT_CONFIG_GLOBAL cover
44
+ git >= 2.32; HOME and XDG_CONFIG_HOME cover older git, and are set to the
45
+ repo's parent directory -- always a per-test directory under pytest's
46
+ tmp_path -- so nothing is ever planted inside the working tree.
47
+ - gitattributes, a separate source GIT_CONFIG_NOSYSTEM does not cover: a
48
+ system `* -diff` rule would make numstat call every path binary and take
49
+ the churn assertions down with it. GIT_ATTR_NOSYSTEM shuts it out.
50
+ - the locale. LC_ALL/LANG are pinned to C, matching _run/_proc's existing
51
+ pin, so fixture git's text output cannot vary with the developer's
52
+ locale. Inheriting the environment is what makes this pin necessary:
53
+ the old four-variable env had no locale in it to inherit.
54
+ """
55
+ env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
56
+ env.update(_IDENT)
57
+ env["GIT_CONFIG_NOSYSTEM"] = "1"
58
+ env["GIT_ATTR_NOSYSTEM"] = "1"
59
+ env["GIT_CONFIG_GLOBAL"] = os.devnull
60
+ env["HOME"] = env["XDG_CONFIG_HOME"] = str(Path(repo).parent)
61
+ env["LC_ALL"] = env["LANG"] = "C"
62
+ return env
63
+
64
+
65
+ def _json(proc):
66
+ """Parse the JSON-only stdout contract, surfacing a crash instead of hiding
67
+ it behind a JSONDecodeError."""
68
+ assert proc.stdout, f"empty stdout; stderr was: {proc.stderr}"
69
+ assert "Traceback" not in proc.stderr, proc.stderr
70
+ return json.loads(proc.stdout)
71
+
72
+
73
+ def _run(*args):
74
+ # LC_ALL=C keeps git's error strings in English so assertions on them
75
+ # are stable across locales.
76
+ proc = subprocess.run(
77
+ ["uv", "run", str(SCRIPT), *args],
78
+ capture_output=True,
79
+ text=True,
80
+ env={**os.environ, "LC_ALL": "C", "LANG": "C"},
81
+ )
82
+ return proc.returncode, _json(proc)
83
+
84
+
85
+ def _proc(*args, env=None):
86
+ """Run the script and return the raw process, so a test can assert on the
87
+ exit code and stderr together — and so `env` can carry an overlay (a fake
88
+ `git` earlier on PATH) that `_run` has no way to pass."""
89
+ overlay = {"LC_ALL": "C", "LANG": "C"}
90
+ if env:
91
+ overlay.update(env)
92
+ return subprocess.run(
93
+ ["uv", "run", str(SCRIPT), *args],
94
+ capture_output=True,
95
+ text=True,
96
+ env={**os.environ, **overlay},
97
+ )
98
+
99
+
100
+ def _git(repo, *args):
101
+ subprocess.run(
102
+ ["git", "-C", str(repo), *args],
103
+ check=True,
104
+ capture_output=True,
105
+ env=_git_env(repo),
106
+ )
107
+
108
+
109
+ def _git_unchecked(repo, *args):
110
+ """`git` that tolerates a non-zero exit — the conflicting merge in
111
+ `_merge_repo` is supposed to fail, and the hand resolution comes after it.
112
+ Same environment as `_git`, which runs with check=True."""
113
+ return subprocess.run(
114
+ ["git", "-C", str(repo), *args],
115
+ capture_output=True,
116
+ text=True,
117
+ env=_git_env(repo),
118
+ )
119
+
120
+
121
+ def _make_repo(tmp_path):
122
+ repo = tmp_path / "repo"
123
+ repo.mkdir()
124
+ _git(repo, "init", "-q")
125
+ (repo / "a.py").write_text("one\ntwo\n")
126
+ _git(repo, "add", "-A")
127
+ _git(repo, "commit", "-qm", "epic-1-1 initial a")
128
+ (repo / "a.py").write_text("one\ntwo\nthree\nfour\n")
129
+ (repo / "b.py").write_text("x\n")
130
+ _git(repo, "add", "-A")
131
+ _git(repo, "commit", "-qm", "epic-1-2 grow a, add b")
132
+ return repo
133
+
134
+
135
+ def test_no_range_returns_empty(tmp_path):
136
+ repo = _make_repo(tmp_path)
137
+ code, out = _run("--repo", str(repo))
138
+ assert code == 0
139
+ assert out["range"] is None
140
+ assert out["commits"] == [] and out["files"] == []
141
+
142
+
143
+ def test_measures_commits_and_files_with_attribution(tmp_path):
144
+ repo = _make_repo(tmp_path)
145
+ code, out = _run(
146
+ "--repo", str(repo), "--range", "HEAD~1..HEAD", "--stories", "1-2,1-1"
147
+ )
148
+ assert code == 0
149
+ assert out["range"] == "HEAD~1..HEAD"
150
+ assert out["commit_count"] == 1
151
+ # The single commit in range is the second one; attributed to story "1-2".
152
+ assert out["commits"][0]["stories"] == ["1-2"]
153
+ files = {f["path"]: f for f in out["files"]}
154
+ # a.py grew by two lines, b.py added one — measured, not judged.
155
+ assert files["a.py"]["added"] == 2 and files["a.py"]["net"] == 2
156
+ assert files["b.py"]["added"] == 1
157
+
158
+
159
+ def test_story_attribution_respects_word_boundary(tmp_path):
160
+ # Story id "1-2" must NOT match a commit subject mentioning "11-2".
161
+ repo = tmp_path / "repo"
162
+ repo.mkdir()
163
+ _git(repo, "init", "-q")
164
+ (repo / "f.py").write_text("a\n")
165
+ _git(repo, "add", "-A")
166
+ _git(repo, "commit", "-qm", "base")
167
+ (repo / "f.py").write_text("a\nb\n")
168
+ _git(repo, "add", "-A")
169
+ _git(repo, "commit", "-qm", "epic-11-2 unrelated story")
170
+ code, out = _run("--repo", str(repo), "--range", "HEAD~1..HEAD", "--stories", "1-2")
171
+ assert code == 0
172
+ assert out["commits"][0]["stories"] == []
173
+
174
+
175
+ def test_bad_range_errors_as_json(tmp_path):
176
+ repo = _make_repo(tmp_path)
177
+ code, out = _run("--repo", str(repo), "--range", "nope..alsonope")
178
+ assert code == 1
179
+ assert out["ok"] is False and out["error"]
180
+
181
+
182
+ def test_single_rev_range_rejected(tmp_path):
183
+ # A single rev is not a range: git would log ALL history up to it and the
184
+ # script would report the whole repo as the epic's evidence.
185
+ repo = _make_repo(tmp_path)
186
+ code, out = _run("--repo", str(repo), "--range", "HEAD")
187
+ assert code == 2
188
+ assert out["ok"] is False and "invalid --range" in out["error"]
189
+
190
+
191
+ def test_pathspec_range_rejected(tmp_path):
192
+ # A path that exists must not be silently consumed as a pathspec.
193
+ repo = _make_repo(tmp_path)
194
+ code, out = _run("--repo", str(repo), "--range", "a.py")
195
+ assert code == 2
196
+ assert out["ok"] is False and "invalid --range" in out["error"]
197
+
198
+
199
+ def test_range_shaped_pathspec_forced_to_rev_parse(tmp_path):
200
+ # A committed file literally named "a..b" passes the REV..REV shape check;
201
+ # without the trailing "--" in the git argv, git silently logs that FILE's
202
+ # history with exit 0. The "--" forces rev interpretation, so this must
203
+ # error instead of measuring the decoy.
204
+ repo = _make_repo(tmp_path)
205
+ (repo / "a..b").write_text("decoy\n")
206
+ _git(repo, "add", "-A")
207
+ _git(repo, "commit", "-qm", "add decoy file named like a range")
208
+ code, out = _run("--repo", str(repo), "--range", "a..b")
209
+ assert code == 1
210
+ assert out["ok"] is False and "bad revision" in out["error"]
211
+
212
+
213
+ def test_option_like_range_rejected(tmp_path):
214
+ # A range starting with "-" must never reach git, where it would be
215
+ # consumed as an option (e.g. --output=... writes an arbitrary file).
216
+ repo = _make_repo(tmp_path)
217
+ code, out = _run("--repo", str(repo), "--range=--output=evil.txt")
218
+ assert code == 2
219
+ assert out["ok"] is False and "invalid --range" in out["error"]
220
+ assert not (repo / "evil.txt").exists()
221
+
222
+
223
+ def test_degenerate_range_shapes_rejected(tmp_path):
224
+ # Shapes that contain ".." but are not REV..REV: git would silently
225
+ # default an empty endpoint to HEAD ("..", "a..", "..HEAD"), a leading
226
+ # dash must never reach git even when dots are present ("-3..HEAD"),
227
+ # unstripped values must not slip past the dash guard, and a three-dot
228
+ # range is a symmetric difference — git would measure commits reachable
229
+ # from either endpoint but not both, a different evidence set entirely.
230
+ repo = _make_repo(tmp_path)
231
+ for bad in (
232
+ "..",
233
+ "a..",
234
+ "..HEAD",
235
+ "-3..HEAD",
236
+ " HEAD~1..HEAD",
237
+ "HEAD~1...HEAD",
238
+ "a...b",
239
+ ):
240
+ code, out = _run("--repo", str(repo), f"--range={bad}")
241
+ assert code == 2, f"accepted {bad!r}"
242
+ assert out["ok"] is False and "invalid --range" in out["error"], bad
243
+
244
+
245
+ def test_malformed_args_emit_json_not_usage(tmp_path):
246
+ # An unknown flag must still land on the JSON contract, not argparse's
247
+ # plain usage text on stderr.
248
+ code, out = _run("--bogus-flag")
249
+ assert code != 0
250
+ assert out["ok"] is False and out["error"]
251
+
252
+
253
+ def test_help_flags_emit_json_not_usage():
254
+ # argparse's built-in help action bypasses the error() override entirely --
255
+ # it prints usage text on stdout and exits 0, which breaks the JSON-only
256
+ # contract for a machine consumer. add_help=False demotes -h to an ordinary
257
+ # unrecognized argument, which error() already handles.
258
+ for flag in ("-h", "--help"):
259
+ proc = _proc(flag)
260
+ # Exit 2 specifically: the module docstring reserves 2 for argument
261
+ # errors and 1 for git/I-O failures, so collapsing them must fail here.
262
+ assert proc.returncode == 2, flag
263
+ assert "usage:" not in proc.stdout, flag
264
+ out = _json(proc)
265
+ assert out["ok"] is False and out["error"], flag
266
+
267
+
268
+ # --- helpers for the fixtures below -----------------------------------------
269
+
270
+
271
+ def _rev(repo, ref):
272
+ return _git_unchecked(repo, "rev-parse", ref).stdout.strip()
273
+
274
+
275
+ def _fake_git(tmp_path, body):
276
+ """Write a `git` shim and return the PATH overlay that puts it ahead of the
277
+ real binary for the script's own subprocesses (never for the fixtures,
278
+ which build their repos through `_git`'s own environment)."""
279
+ bindir = tmp_path / "fakebin"
280
+ bindir.mkdir()
281
+ shim = bindir / "git"
282
+ shim.write_text(body)
283
+ os.chmod(shim, 0o755)
284
+ return {"PATH": f"{bindir}{os.pathsep}{os.environ['PATH']}"}
285
+
286
+
287
+ def _merge_repo(tmp_path):
288
+ """Two story branches merged into the mainline; the second merge conflicts
289
+ and is resolved by hand, adding a line neither branch had. Returns
290
+ (repo, base_sha) — `base_sha..HEAD` is the epic range."""
291
+ repo = tmp_path / "repo"
292
+ repo.mkdir()
293
+ _git(repo, "init", "-q", "-b", "main")
294
+ (repo / "s.py").write_text("l1\nl2\nl3\n")
295
+ _git(repo, "add", "-A")
296
+ _git(repo, "commit", "-qm", "base")
297
+ base = _rev(repo, "HEAD")
298
+
299
+ _git(repo, "checkout", "-q", "-b", "s1")
300
+ (repo / "s.py").write_text("l1\nA\nl3\n")
301
+ _git(repo, "commit", "-qam", "epic-1-2 story one")
302
+ _git(repo, "checkout", "-q", "main")
303
+ _git(repo, "merge", "-q", "--no-ff", "s1", "-m", "merge story 1-2")
304
+
305
+ _git(repo, "checkout", "-q", "-b", "s2", base)
306
+ (repo / "s.py").write_text("l1\nB\nl3\n")
307
+ _git(repo, "commit", "-qam", "epic-1-3 story two")
308
+ _git(repo, "checkout", "-q", "main")
309
+ conflicted = _git_unchecked(repo, "merge", "--no-ff", "s2", "-m", "merge story 1-3")
310
+ assert conflicted.returncode != 0, "fixture expected a merge conflict"
311
+ (repo / "s.py").write_text("l1\nAB\nl3\nl4\n") # hand resolution
312
+ _git(repo, "add", "-A")
313
+ _git(repo, "commit", "-qm", "merge story 1-3")
314
+ return repo, base
315
+
316
+
317
+ def test_rename_yields_two_openable_paths(tmp_path):
318
+ # git's default rename detection emits "src/{mod.py => renamed.py}" — an
319
+ # unopenable pseudo-path that also splits one file's churn across keys.
320
+ # --no-renames makes the rename an honest delete + add.
321
+ repo = tmp_path / "repo"
322
+ repo.mkdir()
323
+ _git(repo, "init", "-q", "-b", "main")
324
+ (repo / "src").mkdir()
325
+ (repo / "src" / "mod.py").write_text("l1\nl2\nl3\nl4\nl5\nl6\n")
326
+ _git(repo, "add", "-A")
327
+ _git(repo, "commit", "-qm", "base")
328
+ base = _rev(repo, "HEAD")
329
+ _git(repo, "mv", "src/mod.py", "src/renamed.py")
330
+ _git(repo, "commit", "-qm", "rename mod")
331
+ (repo / "src" / "renamed.py").write_text("l1\nl2\nl3\nl4\nl5\nl6\nl7\n")
332
+ _git(repo, "commit", "-qam", "add a line after the rename")
333
+
334
+ out = _json(_proc("--repo", str(repo), "--range", f"{base}..HEAD"))
335
+ files = {f["path"]: f for f in out["files"]}
336
+ assert not any("=>" in path for path in files), sorted(files)
337
+ assert files["src/mod.py"]["added"] == 0
338
+ assert files["src/mod.py"]["deleted"] == 6
339
+ assert files["src/renamed.py"]["added"] == 7
340
+ assert files["src/renamed.py"]["deleted"] == 0
341
+
342
+
343
+ def _accented_repo(tmp_path):
344
+ repo = tmp_path / "repo"
345
+ repo.mkdir()
346
+ _git(repo, "init", "-q", "-b", "main")
347
+ (repo / "src").mkdir()
348
+ (repo / "src" / "café.py").write_text("ca\n")
349
+ _git(repo, "add", "-A")
350
+ _git(repo, "commit", "-qm", "base")
351
+ base = _rev(repo, "HEAD")
352
+ (repo / "src" / "café.py").write_text("ca\ncb\n")
353
+ _git(repo, "commit", "-qam", "touch the accented file")
354
+ return repo, base
355
+
356
+
357
+ def _assert_accented_path(repo, out):
358
+ paths = [f["path"] for f in out["files"]]
359
+ assert len(paths) == 1
360
+ assert "\\" not in paths[0] and '"' not in paths[0], paths
361
+ assert unicodedata.normalize("NFC", paths[0]) == "src/café.py"
362
+ # The reported path is a real path: it opens under --repo.
363
+ assert (repo / paths[0]).read_text() == "ca\ncb\n"
364
+
365
+
366
+ def test_non_ascii_path_is_a_real_string(tmp_path):
367
+ # Without core.quotePath=false git emits "src/caf\303\251.py" — quoted and
368
+ # octal-escaped, so the documented "open the ranked files" step cannot.
369
+ repo, base = _accented_repo(tmp_path)
370
+ _assert_accented_path(
371
+ repo, _json(_proc("--repo", str(repo), "--range", f"{base}..HEAD"))
372
+ )
373
+
374
+
375
+ def test_non_ascii_path_survives_a_non_utf8_locale(tmp_path):
376
+ # Pins the explicit encoding="utf-8" on the subprocess. Modern CPython's
377
+ # UTF-8 mode hides its absence even under LC_ALL=C, so the pin only bites
378
+ # with UTF-8 mode and C-locale coercion both off — where the interpreter
379
+ # default is US-ASCII and git's UTF-8 path bytes fail to decode, taking the
380
+ # whole measurement down with them.
381
+ repo, base = _accented_repo(tmp_path)
382
+ out = _json(
383
+ _proc(
384
+ "--repo",
385
+ str(repo),
386
+ "--range",
387
+ f"{base}..HEAD",
388
+ env={"PYTHONUTF8": "0", "PYTHONCOERCECLOCALE": "0"},
389
+ )
390
+ )
391
+ _assert_accented_path(repo, out)
392
+
393
+
394
+ def test_merge_churn_is_measured_and_counted(tmp_path):
395
+ repo, base = _merge_repo(tmp_path)
396
+ out = _json(_proc("--repo", str(repo), "--range", f"{base}..HEAD"))
397
+ assert out["commit_count"] == 4
398
+ assert out["merge_count"] == 2
399
+ assert out["merges_measured"] == 2
400
+ # Both merges' first-parent churn: 1/1 for the clean merge, 2/1 for the
401
+ # hand-resolved one (the resolution added a line neither branch had).
402
+ merge_files = {f["path"]: f for f in out["merge_files"]}
403
+ assert merge_files["s.py"]["added"] == 3
404
+ assert merge_files["s.py"]["deleted"] == 2
405
+ assert merge_files["s.py"]["net"] == 1
406
+ assert merge_files["s.py"]["commit_count"] == 2
407
+
408
+
409
+ def test_merge_commits_listed_but_excluded_from_files(tmp_path):
410
+ repo, base = _merge_repo(tmp_path)
411
+ out = _json(_proc("--repo", str(repo), "--range", f"{base}..HEAD"))
412
+ by_subject = {c["subject"]: c for c in out["commits"]}
413
+ assert by_subject["merge story 1-2"]["is_merge"] is True
414
+ assert by_subject["merge story 1-3"]["is_merge"] is True
415
+ assert by_subject["epic-1-2 story one"]["is_merge"] is False
416
+ # `files` is the two story commits only — 1/1 each. Folding the merges in
417
+ # would double count: their diff restates the churn they merged.
418
+ files = {f["path"]: f for f in out["files"]}
419
+ assert files["s.py"]["added"] == 2
420
+ assert files["s.py"]["deleted"] == 2
421
+ assert files["s.py"]["commit_count"] == 2
422
+
423
+
424
+ def test_story_attribution_survives_merges(tmp_path):
425
+ repo, base = _merge_repo(tmp_path)
426
+ out = _json(
427
+ _proc(
428
+ "--repo", str(repo), "--range", f"{base}..HEAD", "--stories", "1-2,1-3"
429
+ )
430
+ )
431
+ by_subject = {c["subject"]: c for c in out["commits"]}
432
+ assert by_subject["epic-1-2 story one"]["stories"] == ["1-2"]
433
+ assert by_subject["epic-1-3 story two"]["stories"] == ["1-3"]
434
+
435
+
436
+ def test_off_spine_merge_is_counted_but_not_measured(tmp_path):
437
+ # A back-merge of the mainline into a story branch is a merge in the range,
438
+ # but its first-parent diff would restate unrelated mainline content as
439
+ # epic churn. It stays in merge_count and out of merges_measured, so the
440
+ # gap between the two says plainly that a merge went unmeasured.
441
+ repo = tmp_path / "repo"
442
+ repo.mkdir()
443
+ _git(repo, "init", "-q", "-b", "main")
444
+ (repo / "f.txt").write_text("base\n")
445
+ _git(repo, "add", "-A")
446
+ _git(repo, "commit", "-qm", "base")
447
+ base = _rev(repo, "HEAD")
448
+ (repo / "f.txt").write_text("base\nmain1\n")
449
+ _git(repo, "commit", "-qam", "mainline work")
450
+
451
+ _git(repo, "checkout", "-q", "-b", "s1", base)
452
+ (repo / "s1.txt").write_text("s1\n")
453
+ _git(repo, "add", "-A")
454
+ _git(repo, "commit", "-qm", "epic-1-2 story one")
455
+ _git(repo, "merge", "-q", "--no-ff", "main", "-m", "back-merge main into s1")
456
+ _git(repo, "checkout", "-q", "main")
457
+ _git(repo, "merge", "-q", "--no-ff", "s1", "-m", "merge story 1-2")
458
+
459
+ out = _json(_proc("--repo", str(repo), "--range", f"{base}..HEAD"))
460
+ assert out["merge_count"] == 2
461
+ assert out["merges_measured"] == 1
462
+ merge_files = {f["path"]: f for f in out["merge_files"]}
463
+ assert set(merge_files) == {"s1.txt"}
464
+
465
+
466
+ def test_merge_pass_survives_a_hostile_log_diffmerges_config(tmp_path):
467
+ # `-m` means "whatever log.diffMerges says", so a user or repo config of
468
+ # `off` makes the merge pass emit no file rows: merge_files comes back
469
+ # empty beside a non-zero merges_measured and reads as "the merges changed
470
+ # nothing". The command-line -c pin beats the config.
471
+ repo, base = _merge_repo(tmp_path)
472
+ _git(repo, "config", "log.diffMerges", "off")
473
+ out = _json(_proc("--repo", str(repo), "--range", f"{base}..HEAD"))
474
+ assert out["merges_measured"] == 2
475
+ merge_files = {f["path"]: f for f in out["merge_files"]}
476
+ assert merge_files["s.py"]["added"] == 3
477
+ assert merge_files["s.py"]["deleted"] == 2
478
+
479
+
480
+ def test_distinct_non_utf8_paths_stay_distinct(tmp_path):
481
+ # errors="replace" maps every invalid byte to the same U+FFFD, collapsing
482
+ # two different files into one `files` key with their churn summed —
483
+ # measurement corruption with nothing in the output admitting to it.
484
+ overlay = _fake_git(
485
+ tmp_path,
486
+ "#!/bin/sh\n"
487
+ "printf 'aaaa\\037\\037subj\\n\\n1\\t0\\tsrc/caf\\351.py\\n"
488
+ "2\\t0\\tsrc/caf\\377.py\\n'\n",
489
+ )
490
+ out = _json(_proc("--repo", str(tmp_path), "--range", "a..b", env=overlay))
491
+ files = {f["path"]: f for f in out["files"]}
492
+ assert len(files) == 2, files
493
+ assert sorted(f["added"] for f in files.values()) == [1, 2]
494
+
495
+
496
+ def test_repeated_merge_headers_are_counted_once(tmp_path):
497
+ # Pins the dedupe guard in _parse_log. git before 2.31 does not honour
498
+ # --first-parent for `-m`'s diff format, so a merge's header repeats once
499
+ # per parent with a diff block under each. Without the guard that doubles
500
+ # the merge churn and pushes merges_measured above merge_count, inverting
501
+ # the invariant the reference documents. Inert on modern git, so it needs
502
+ # pre-2.31-shaped output to be exercised at all.
503
+ real_git = shutil.which("git")
504
+ assert real_git, "git must be on PATH"
505
+ repo, base = _merge_repo(tmp_path)
506
+ overlay = _fake_git(
507
+ tmp_path,
508
+ "#!/bin/sh\n"
509
+ 'for a in "$@"; do\n'
510
+ ' if [ "$a" = "--min-parents=2" ]; then\n'
511
+ " printf 'aaaa\\037p1 p2\\037merge story 1-3\\n\\n2\\t1\\ts.py\\n"
512
+ "aaaa\\037p1 p2\\037merge story 1-3\\n\\n5\\t4\\ts.py\\n'\n"
513
+ " exit 0\n"
514
+ " fi\n"
515
+ "done\n"
516
+ f'exec "{real_git}" "$@"\n',
517
+ )
518
+ out = _json(_proc("--repo", str(repo), "--range", f"{base}..HEAD", env=overlay))
519
+ # One merge sha, however many blocks git printed for it.
520
+ assert out["merges_measured"] == 1
521
+ assert out["merges_measured"] <= out["merge_count"]
522
+ merge_files = {f["path"]: f for f in out["merge_files"]}
523
+ # The first block is the first-parent diff on every git version; the
524
+ # repeat must not be added on top of it.
525
+ assert merge_files["s.py"]["added"] == 2
526
+ assert merge_files["s.py"]["deleted"] == 1
527
+ assert merge_files["s.py"]["commit_count"] == 1
528
+
529
+
530
+ def test_valid_range_with_no_commits_keeps_the_full_shape(tmp_path):
531
+ # A mis-specified epic range is a valid, empty range. It must still answer
532
+ # with every documented key rather than a differently-shaped stub.
533
+ repo = _make_repo(tmp_path)
534
+ proc = _proc("--repo", str(repo), "--range", "HEAD..HEAD")
535
+ out = _json(proc)
536
+ assert proc.returncode == 0
537
+ assert set(out) == {
538
+ "range",
539
+ "commit_count",
540
+ "merge_count",
541
+ "merges_measured",
542
+ "commits",
543
+ "files",
544
+ "merge_files",
545
+ "stories_supplied",
546
+ }
547
+ assert out["commit_count"] == 0
548
+ assert out["merge_count"] == 0
549
+ assert out["merges_measured"] == 0
550
+ assert out["commits"] == [] and out["files"] == [] and out["merge_files"] == []
551
+
552
+
553
+ def test_linear_history_reports_no_merges(tmp_path):
554
+ repo = _make_repo(tmp_path)
555
+ out = _json(_proc("--repo", str(repo), "--range", "HEAD~1..HEAD"))
556
+ assert out["merge_count"] == 0
557
+ assert out["merges_measured"] == 0
558
+ assert out["merge_files"] == []
559
+
560
+
561
+ def _recording_git(tmp_path, calls):
562
+ """A `git` shim that records each invocation as one \\x1f-delimited record
563
+ (one field per argument, so argument boundaries survive) and then execs the
564
+ real git."""
565
+ real_git = shutil.which("git")
566
+ assert real_git, "git must be on PATH"
567
+ return _fake_git(
568
+ tmp_path,
569
+ "#!/bin/sh\n"
570
+ f"( printf '%s\\037' \"$@\"; printf '\\n' ) >> \"{calls}\"\n"
571
+ f'exec "{real_git}" "$@"\n',
572
+ )
573
+
574
+
575
+ def _numstat_invocations(calls):
576
+ """The recorded `git log --numstat` invocations, each as an argument list."""
577
+ out = []
578
+ for line in calls.read_text().splitlines():
579
+ argv = [field for field in line.split("\x1f") if field]
580
+ if "--numstat" in argv:
581
+ out.append(argv)
582
+ return out
583
+
584
+
585
+ def test_second_pass_runs_only_when_the_range_has_merges(tmp_path):
586
+ # The merge pass is skipped outright on linear history, so the common case
587
+ # still costs exactly one `git log` — and the two passes must differ in
588
+ # exactly the arguments the design depends on.
589
+ calls = tmp_path / "calls.log"
590
+ overlay = _recording_git(tmp_path, calls)
591
+ merge_args = {"-m", "--first-parent", "--min-parents=2"}
592
+
593
+ (tmp_path / "linear").mkdir()
594
+ (tmp_path / "merged").mkdir()
595
+
596
+ linear = _make_repo(tmp_path / "linear")
597
+ _json(_proc("--repo", str(linear), "--range", "HEAD~1..HEAD", env=overlay))
598
+ logs = _numstat_invocations(calls)
599
+ assert len(logs) == 1, logs
600
+
601
+ calls.write_text("")
602
+ merged, base = _merge_repo(tmp_path / "merged")
603
+ _json(_proc("--repo", str(merged), "--range", f"{base}..HEAD", env=overlay))
604
+ logs = _numstat_invocations(calls)
605
+ assert len(logs) == 2, logs
606
+ # Pass 1 keeps full topology: none of the merge-pass arguments may reach it,
607
+ # or the story-branch commits drop out of the listing and attribution dies.
608
+ assert merge_args.isdisjoint(logs[0]), logs[0]
609
+ assert merge_args.issubset(logs[1]), logs[1]
610
+
611
+
612
+ def test_multi_story_subject_attributes_to_every_match(tmp_path):
613
+ # First-match-wins silently dropped the second story from the attribution.
614
+ repo = tmp_path / "repo"
615
+ repo.mkdir()
616
+ _git(repo, "init", "-q", "-b", "main")
617
+ (repo / "f.py").write_text("a\n")
618
+ _git(repo, "add", "-A")
619
+ _git(repo, "commit", "-qm", "base")
620
+ (repo / "f.py").write_text("a\nb\n")
621
+ _git(repo, "commit", "-qam", "fix seam between 1-2 and 1-3")
622
+
623
+ out = _json(
624
+ _proc(
625
+ "--repo", str(repo), "--range", "HEAD~1..HEAD", "--stories", "1-3,1-2"
626
+ )
627
+ )
628
+ # Every match, in --stories order — not whichever id was passed first.
629
+ assert out["commits"][0]["stories"] == ["1-3", "1-2"]
630
+
631
+ # A repeated id must not list the commit twice: any per-story total built
632
+ # from `stories` would count it twice.
633
+ repeated = _json(
634
+ _proc(
635
+ "--repo", str(repo), "--range", "HEAD~1..HEAD", "--stories", "1-3,1-2,1-3"
636
+ )
637
+ )
638
+ assert repeated["commits"][0]["stories"] == ["1-3", "1-2"]
639
+ assert repeated["stories_supplied"] == ["1-3", "1-2"]
640
+
641
+
642
+ def test_subject_naming_no_story_gets_an_empty_list(tmp_path):
643
+ repo = tmp_path / "repo"
644
+ repo.mkdir()
645
+ _git(repo, "init", "-q", "-b", "main")
646
+ (repo / "f.py").write_text("a\n")
647
+ _git(repo, "add", "-A")
648
+ _git(repo, "commit", "-qm", "base")
649
+ (repo / "f.py").write_text("a\nb\n")
650
+ _git(repo, "commit", "-qam", "chore: tidy imports")
651
+
652
+ out = _json(
653
+ _proc(
654
+ "--repo", str(repo), "--range", "HEAD~1..HEAD", "--stories", "1-2,1-3"
655
+ )
656
+ )
657
+ assert out["commits"][0]["stories"] == []
658
+
659
+
660
+ def test_git_failure_with_empty_stderr_reports_the_exit_code(tmp_path):
661
+ # A quiet git failure (signal kill, empty stderr) must not leave the caller
662
+ # with `"error": ""` and nothing to report.
663
+ overlay = _fake_git(tmp_path, "#!/bin/sh\nexit 3\n")
664
+ proc = _proc("--repo", str(tmp_path), "--range", "HEAD~1..HEAD", env=overlay)
665
+ out = _json(proc)
666
+ assert proc.returncode == 1
667
+ assert out["ok"] is False
668
+ assert out["error"] == "git exited 3"
669
+
670
+
671
+ def _binary_repo(tmp_path):
672
+ """A binary-only path plus a path that is binary twice and text once."""
673
+ repo = tmp_path / "repo"
674
+ repo.mkdir()
675
+ _git(repo, "init", "-q", "-b", "main")
676
+ (repo / "keep.txt").write_text("keep\n")
677
+ _git(repo, "add", "-A")
678
+ _git(repo, "commit", "-qm", "base")
679
+ base = _rev(repo, "HEAD")
680
+
681
+ (repo / "src").mkdir()
682
+ (repo / "src" / "x.py").write_bytes(b"\x00\x01bin\n")
683
+ (repo / "blob.bin").write_bytes(b"\x00\x01blob\n")
684
+ _git(repo, "add", "-A")
685
+ _git(repo, "commit", "-qm", "add binary content")
686
+ (repo / "src" / "x.py").write_text("one\ntwo\n") # binary -> text: still binary
687
+ _git(repo, "commit", "-qam", "x.py becomes text")
688
+ (repo / "src" / "x.py").write_text("one\ntwo\nthree\n") # text -> text: measured
689
+ _git(repo, "commit", "-qam", "grow x.py")
690
+ (repo / "blob.bin").write_bytes(b"\x00\x02blob\n")
691
+ _git(repo, "commit", "-qam", "churn the blob")
692
+ return repo, base
693
+
694
+
695
+ def test_binary_revisions_no_longer_erase_measured_text_churn(tmp_path):
696
+ repo, base = _binary_repo(tmp_path)
697
+ out = _json(_proc("--repo", str(repo), "--range", f"{base}..HEAD"))
698
+ x = {f["path"]: f for f in out["files"]}["src/x.py"]
699
+ assert x["added"] == 1 and x["deleted"] == 0 and x["net"] == 1
700
+ assert x["binary_revisions"] == 2
701
+ assert x["commit_count"] == 3
702
+
703
+
704
+ def test_binary_only_path_reports_zero_sums_and_its_revision_count(tmp_path):
705
+ repo, base = _binary_repo(tmp_path)
706
+ out = _json(_proc("--repo", str(repo), "--range", f"{base}..HEAD"))
707
+ blob = {f["path"]: f for f in out["files"]}["blob.bin"]
708
+ assert blob["added"] == 0 and blob["deleted"] == 0 and blob["net"] == 0
709
+ assert blob["binary_revisions"] == 2
710
+ assert blob["commit_count"] == 2
711
+
712
+
713
+ def test_success_shape_carries_every_documented_key(tmp_path):
714
+ repo = _make_repo(tmp_path)
715
+ out = _json(_proc("--repo", str(repo), "--range", "HEAD~1..HEAD"))
716
+ assert set(out) >= {
717
+ "range",
718
+ "commit_count",
719
+ "merge_count",
720
+ "merges_measured",
721
+ "commits",
722
+ "files",
723
+ "merge_files",
724
+ "stories_supplied",
725
+ }
726
+ assert set(out["commits"][0]) == {"sha", "subject", "stories", "is_merge"}
727
+ assert set(out["files"][0]) == {
728
+ "path",
729
+ "added",
730
+ "deleted",
731
+ "net",
732
+ "commit_count",
733
+ "binary_revisions",
734
+ }
735
+
736
+
737
+ def test_explicit_repo_ignores_ambient_git_dir(tmp_path):
738
+ repo = _make_repo(tmp_path)
739
+ proc = _proc(
740
+ "--repo",
741
+ str(repo),
742
+ "--range",
743
+ "HEAD~1..HEAD",
744
+ env={"GIT_DIR": str(tmp_path / "wrong-git-dir")},
745
+ )
746
+ assert _json(proc)["commit_count"] == 1
747
+
748
+
749
+ if __name__ == "__main__":
750
+ sys.exit(pytest.main([__file__, "-q"]))