Skills › BMAD Method › bmad-retrospective › git_evidence.py
git_evidence.py
295 lines · skills/bmad-retrospective/scripts/git_evidence.py at bd70c64
1# /// script
2# requires-python = ">=3.11"
3# ///
4"""Measure git commit and file-change evidence over a revision range.
6Prints ONLY JSON to stdout. Errors are emitted as JSON to stdout with a
7non-zero exit code: 2 for invalid arguments (rejected before git runs),
81 for git or I/O failures. This script only MEASURES — it never judges
9acceleration or violations. The model interprets the numbers.
11Two git passes. The first lists every commit in the range (merges included)
12and sums the per-file churn of the non-merge commits, which is what `files`
13reports. The second runs only when the range contains merges and measures
14those merges alone, reported separately as `merge_files` — never folded into
15`files`, because a merge's diff against its first parent restates the churn
16of the commits it merged in, which the first pass already counted.
17"""
19import argparse
20import json
21import os
22import re
23import subprocess
24import sys
26UNIT_SEP = "\x1f"
27# sha, space-separated parents (empty for a root commit), subject.
28LOG_FORMAT = f"--format=%H{UNIT_SEP}%P{UNIT_SEP}%s"
31def _emit(obj, code=0):
32 sys.stdout.write(json.dumps(obj))
33 sys.exit(code)
36class JsonArgumentParser(argparse.ArgumentParser):
37 """Emit argparse failures on the JSON-only stdout contract, not usage text.
39 The parser is constructed with ``add_help=False``. The override below covers
40 ``error()``, but ``-h`` never reaches it: the built-in help action calls
41 ``print_help()`` and ``exit(0)`` directly, which would put plain usage text
42 on stdout with a zero exit and break the JSON-only contract. Removing the
43 action instead of intercepting it routes ``-h`` through the already-tested
44 ``error()`` path as an ordinary unrecognized argument. The cost is that the
45 ``help=`` strings are unreachable from the CLI; the skill's references carry
46 the usage a human needs.
47 """
49 def error(self, message):
50 _emit({"ok": False, "error": f"argument error: {message}"}, 2)
53def _parse_numstat_line(line):
54 # numstat lines: "<added>\t<deleted>\t<path>"; binary files use "-".
55 parts = line.split("\t")
56 if len(parts) < 3:
57 return None
58 added_raw, deleted_raw, path = parts[0], parts[1], "\t".join(parts[2:])
59 added = None if added_raw == "-" else int(added_raw)
60 deleted = None if deleted_raw == "-" else int(deleted_raw)
61 return added, deleted, path
64def _git_log(repo, extra_args, rng):
65 """Run one `git log --numstat` pass over `rng` and return its stdout.
67 `core.quotePath=false` keeps non-ASCII paths as real UTF-8 strings instead
68 of octal escapes, and `--no-renames` makes a rename an honest delete + add
69 instead of an unopenable "src/{a => b}" pseudo-path that splits one file's
70 churn across several keys. Both matter for every pass, so both live here.
72 `log.diffMerges=separate` is pinned on the command line because it is what
73 `-m` means: a user or repo config setting it to `off` makes pass 2 emit no
74 file rows at all, so `merge_files` would come back empty beside a non-zero
75 `merges_measured` and read as "the merges changed nothing".
76 """
77 cmd = [
78 "git",
79 "-c",
80 "core.quotePath=false",
81 "-c",
82 "log.diffMerges=separate",
83 "-C",
84 repo,
85 "log",
86 "--numstat",
87 "--no-renames",
88 *extra_args,
89 LOG_FORMAT,
90 rng,
91 "--", # terminate rev parsing so the range can never match a pathspec
92 ]
93 try:
94 # Decode explicitly: git emits UTF-8 path bytes regardless of the
95 # caller's locale, and a C locale would otherwise decode them as ASCII.
96 # surrogateescape, not replace: replace maps every invalid byte to the
97 # same U+FFFD, so two distinct non-UTF-8 paths would collapse into one
98 # `files` key with their churn silently summed. Lone surrogates survive
99 # json.dumps (escaped as \udcXX under ensure_ascii) and json.loads.
100 proc = subprocess.run(
101 cmd,
102 capture_output=True,
103 text=True,
104 encoding="utf-8",
105 errors="surrogateescape",
106 env={k: v for k, v in os.environ.items() if not k.startswith("GIT_")},
107 )
108 except Exception as exc: # noqa: BLE001
109 _emit({"ok": False, "error": str(exc)}, 1)
111 if proc.returncode != 0:
112 # stderr can be empty (a signal kill, a quiet failure); the exit code is
113 # then the only thing left to report, so never emit an empty error.
114 _emit(
115 {
116 "ok": False,
117 "error": proc.stderr.strip() or f"git exited {proc.returncode}",
118 },
119 1,
120 )
121 return proc.stdout
124def _parse_log(output, stories):
125 """Turn one pass's log output into (commits, files_map). Shared by both."""
126 commits = []
127 files = {} # path -> {path, _added, _deleted, binary_revisions, commit_count}
128 seen = set()
129 counting = True
131 for raw in output.splitlines():
132 if UNIT_SEP in raw:
133 sha, parents, subject = raw.split(UNIT_SEP, 2)
134 # Under -m, git repeats a merge's header once per parent unless it
135 # also honours --first-parent (git 2.31+). Count only the first
136 # block for a sha — git emits parents in order, so that block is
137 # the first-parent diff either way, and no churn is double counted.
138 counting = sha not in seen
139 if not counting:
140 continue
141 seen.add(sha)
142 commits.append(
143 {
144 "sha": sha,
145 "subject": subject,
146 # Every id the subject names, in --stories order: a commit
147 # spanning two stories belongs to both. Word-boundary match
148 # so a story id like "1-2" does not also match "11-2".
149 "stories": [sid for sid in stories if re.search(rf"\b{re.escape(sid)}\b", subject)],
150 "is_merge": len(parents.split()) > 1,
151 }
152 )
153 continue
155 if not counting or not raw.strip():
156 continue
158 parsed = _parse_numstat_line(raw)
159 if parsed is None:
160 continue
161 added, deleted, path = parsed
163 entry = files.get(path)
164 if entry is None:
165 # _added/_deleted are running sums over the path's text revisions.
166 entry = {
167 "path": path,
168 "_added": 0,
169 "_deleted": 0,
170 "binary_revisions": 0,
171 "commit_count": 0,
172 }
173 files[path] = entry
175 entry["commit_count"] += 1
176 if added is None or deleted is None:
177 # A binary revision is unmeasurable, not zero — count it alongside
178 # the sums instead of nulling the path's real measured churn.
179 entry["binary_revisions"] += 1
180 else:
181 entry["_added"] += added
182 entry["_deleted"] += deleted
184 return commits, files
187def _file_list(files):
188 return [
189 {
190 "path": entry["path"],
191 "added": entry["_added"],
192 "deleted": entry["_deleted"],
193 "net": entry["_added"] - entry["_deleted"],
194 "commit_count": entry["commit_count"],
195 "binary_revisions": entry["binary_revisions"],
196 }
197 for entry in files.values()
198 ]
201def main(argv=None):
202 parser = JsonArgumentParser(
203 description=(
204 "Measure commit and per-file change evidence over a git revision range. Measures only; does not judge."
205 ),
206 add_help=False,
207 )
208 parser.add_argument("--repo", default=".", help="Path to the git repo (default: .)")
209 parser.add_argument("--range", dest="range", help="Revision range REV..REV")
210 parser.add_argument(
211 "--stories",
212 help="Comma-separated story ids to match against commit subjects.",
213 )
214 args = parser.parse_args(argv)
216 stories = []
217 if args.stories:
218 # dict.fromkeys dedupes while keeping the caller's order: a repeated id
219 # would otherwise land twice in a commit's `stories`, double counting
220 # that commit in any per-story total built from the output.
221 stories = list(dict.fromkeys(s.strip() for s in args.stories.split(",") if s.strip()))
223 if not args.range:
224 _emit(
225 {
226 "range": None,
227 "note": "no range supplied",
228 "commits": [],
229 "files": [],
230 }
231 )
233 # Accept only an explicit REV..REV range. Anything else silently measures
234 # the wrong thing: a leading "-" is consumed by git as an option, a single
235 # rev logs all history up to it, a bare pathspec logs by path, an empty
236 # endpoint ("..", "a..", "..b") makes git default that side to HEAD, and a
237 # three-dot "A...B" is a symmetric difference — a different commit set
238 # entirely. partition splits at the FIRST "..", so any of those extra-dot
239 # shapes leaves `right` empty or dot-prefixed.
240 left, _, right = args.range.partition("..")
241 if args.range != args.range.strip() or args.range.startswith("-") or not left or not right or right.startswith("."):
242 _emit(
243 {
244 "ok": False,
245 "error": f"invalid --range {args.range!r}: expected a revision range like REV..REV",
246 },
247 2,
248 )
250 # Pass 1 — the listing. No extra args, so full topology: every commit in
251 # the range including merges, which is what per-story attribution reads.
252 # Merges contribute no numstat rows here, so `files` is non-merge churn.
253 commits, files = _parse_log(_git_log(args.repo, [], args.range), stories)
254 merge_count = sum(1 for commit in commits if commit["is_merge"])
256 # Pass 2 — merge churn, only when there is any. `-m --first-parent
257 # --min-parents=2` walks the range head's first-parent spine and emits
258 # exactly one diff-against-first-parent block per merge sitting on it.
259 # Merges off that spine are counted in merge_count and never measured,
260 # which is precisely why merges_measured is a separate key: the gap
261 # between the two is a visible statement that some merges went
262 # unmeasured. This never folds into `files` — a merge's first-parent diff
263 # restates the churn of the commits it merged in, which pass 1 already
264 # counted, so adding it in would double count.
265 merge_commits, merge_files = [], {}
266 if merge_count:
267 merge_commits, merge_files = _parse_log(
268 _git_log(
269 args.repo,
270 ["-m", "--first-parent", "--min-parents=2"],
271 args.range,
272 ),
273 stories,
274 )
276 _emit(
277 {
278 "range": args.range,
279 "commit_count": len(commits),
280 "merge_count": merge_count,
281 "merges_measured": len(merge_commits),
282 "commits": commits,
283 "files": _file_list(files),
284 "merge_files": _file_list(merge_files),
285 "stories_supplied": stories,
286 }
287 )
290if __name__ == "__main__":
291 if sys.platform == "win32":
292 # Piped output on Windows defaults to a legacy code page, not UTF-8.
293 sys.stdout.reconfigure(encoding="utf-8")
294 sys.stderr.reconfigure(encoding="utf-8")
295 main()