A fork of https://github.com/crosspoint-reader/crosspoint-reader
1#!/usr/bin/env python3
2"""
3Build firmware at selected commits and report flash usage.
4
5Two modes (mutually exclusive, one required):
6
7 --range START END Walk every commit from START (exclusive baseline) to END
8 (inclusive), oldest-first. START is also built so the
9 first delta can be computed. Both refs must lie on the
10 same ancestry path (i.e. one branch).
11
12 --commits REF [...] Build each REF in the order given. Refs may be SHAs,
13 branch names, tags, or relative refs like HEAD~3. They
14 can come from different branches.
15
16Common options:
17 --env ENV PlatformIO build environment (default: "default")
18 --csv [FILE] Output as CSV. Without FILE, writes to stdout.
19
20Output is a human-readable table by default. Use --csv for machine-readable
21output.
22
23Examples:
24 python3 scripts/firmware_size_history.py --range HEAD~5 HEAD
25 python3 scripts/firmware_size_history.py --range abc1234 def5678 --env gh_release --csv sizes.csv
26 python3 scripts/firmware_size_history.py --commits main feature/new-parser
27 python3 scripts/firmware_size_history.py --commits abc1234 def5678 ghi9012 --csv
28"""
29
30import argparse
31import csv
32import re
33import subprocess
34import sys
35
36FLASH_RE = re.compile(
37 r"Flash:.*?(\d+)\s+bytes\s+from\s+(\d+)\s+bytes"
38)
39BOX_CHAR = "\u2500"
40
41
42def run(cmd, capture=True, check=True):
43 result = subprocess.run(
44 cmd, capture_output=capture, text=True, check=check
45 )
46 return result
47
48
49def resolve_ref(ref):
50 """Resolve a git ref to (full_sha, title), or sys.exit with a message."""
51 r = run(["git", "rev-parse", "--verify", ref], check=False)
52 if r.returncode != 0:
53 print(f"[error] Could not resolve ref '{ref}'", file=sys.stderr)
54 sys.exit(1)
55 sha = r.stdout.strip()
56 title = run(["git", "log", "-1", "--format=%s", sha]).stdout.strip()
57 return sha, title
58
59
60def git_current_ref():
61 """Return the current branch name, or the detached commit hash."""
62 r = run(["git", "symbolic-ref", "--short", "HEAD"], check=False)
63 if r.returncode == 0:
64 return r.stdout.strip()
65 return run(["git", "rev-parse", "HEAD"]).stdout.strip()
66
67
68def git_commit_list(start, end):
69 """Return list of (hash, title) from start (exclusive) to end (inclusive), oldest first."""
70 r = run([
71 "git", "log", "--reverse", "--format=%H %s",
72 f"{start}..{end}",
73 ])
74 commits = []
75 for line in r.stdout.strip().splitlines():
76 if not line:
77 continue
78 sha, title = line.split(" ", 1)
79 commits.append((sha, title))
80 return commits
81
82
83def git_checkout(ref):
84 run(["git", "checkout", "--detach", ref], check=True)
85
86
87def build_firmware(env):
88 """Run pio build and return the raw combined stdout+stderr."""
89 result = subprocess.run(
90 ["pio", "run", "-e", env],
91 capture_output=True, text=True, check=False
92 )
93 return result.returncode, result.stdout + "\n" + result.stderr
94
95
96def parse_flash_used(output):
97 """Extract used-bytes integer from PlatformIO output, or None."""
98 m = FLASH_RE.search(output)
99 if m:
100 return int(m.group(1))
101 return None
102
103
104def write_csv(out, rows, fieldnames):
105 """Write rows as CSV to a file-like object."""
106 writer = csv.DictWriter(out, fieldnames=fieldnames)
107 writer.writeheader()
108 writer.writerows(rows)
109
110
111def format_table(rows):
112 """Print rows as an aligned human-readable table to stdout."""
113 COL_COMMIT = 10
114 COL_FLASH = 11
115 COL_DELTA = 7
116
117 def fmt_flash(val):
118 if val == "FAILED":
119 return "FAILED"
120 return f"{val:,}"
121
122 def fmt_delta(val):
123 if val == "" or val is None:
124 return ""
125 return f"{val:+,}"
126
127 header = (
128 f"{'Commit':<{COL_COMMIT}} "
129 f"{'Flash':>{COL_FLASH}} "
130 f"{'Delta':>{COL_DELTA}} "
131 f"Title"
132 )
133 sep = (
134 f"{BOX_CHAR * COL_COMMIT} "
135 f"{BOX_CHAR * COL_FLASH} "
136 f"{BOX_CHAR * COL_DELTA} "
137 f"{BOX_CHAR * 40}"
138 )
139 print(header)
140 print(sep)
141 for row in rows:
142 flash_str = fmt_flash(row["flash_bytes"])
143 delta_str = fmt_delta(row["delta"])
144 print(
145 f"{row['commit']:<{COL_COMMIT}} "
146 f"{flash_str:>{COL_FLASH}} "
147 f"{delta_str:>{COL_DELTA}} "
148 f"{row['title']}"
149 )
150
151
152def build_commits_from_range(start, end):
153 """Validate a range and return (all_commits, description) for the build loop."""
154 start_sha, start_title = resolve_ref(start)
155 resolve_ref(end)
156
157 commits = git_commit_list(start, end)
158 if not commits:
159 print(f"[error] No commits found in range {start}..{end}", file=sys.stderr)
160 sys.exit(1)
161
162 all_commits = [(start_sha, start_title)] + commits
163 desc = f"{len(all_commits)} commits (1 baseline + {len(commits)} in range)"
164 return all_commits, desc
165
166
167def build_commits_from_list(refs):
168 """Resolve each ref and return (all_commits, description) for the build loop."""
169 all_commits = [resolve_ref(ref) for ref in refs]
170 desc = f"{len(all_commits)} commit{'s' if len(all_commits) != 1 else ''}"
171 return all_commits, desc
172
173
174def main():
175 parser = argparse.ArgumentParser(
176 description="Measure firmware flash size across git commits.",
177 epilog=(
178 "Range mode walks every commit between START and END (one branch). "
179 "List mode builds specific refs that may come from different branches."
180 ),
181 )
182
183 mode = parser.add_mutually_exclusive_group(required=True)
184 mode.add_argument(
185 "--range", nargs=2, metavar=("START", "END"),
186 help="Older commit (exclusive baseline) and newer commit (inclusive)",
187 )
188 mode.add_argument(
189 "--commits", nargs="+", metavar="REF",
190 help="One or more git refs to build (SHAs, branches, tags, HEAD~N, ...)",
191 )
192
193 parser.add_argument("--env", default="default", help="PlatformIO environment (default: 'default')")
194 parser.add_argument(
195 "--csv", nargs="?", const="-", default=None, metavar="FILE",
196 help="Output as CSV (default: stdout, or specify FILE)",
197 )
198 args = parser.parse_args()
199
200 # Validate refs before touching the working tree so a bad ref never
201 # leaves uncommitted changes stranded in the stash.
202 if args.range:
203 all_commits, desc = build_commits_from_range(args.range[0], args.range[1])
204 is_range = True
205 else:
206 all_commits, desc = build_commits_from_list(args.commits)
207 is_range = False
208
209 original_ref = git_current_ref()
210 print(f"[info] Will restore to '{original_ref}' when finished.", file=sys.stderr)
211
212 stash_needed = False
213 status = run(["git", "status", "--porcelain"]).stdout.strip()
214 if status:
215 print("[info] Stashing uncommitted changes...", file=sys.stderr)
216 run(["git", "stash", "push", "-m", "firmware_size_history auto-stash"])
217 stash_needed = True
218
219 print(f"[info] Building {desc}...", file=sys.stderr)
220
221 results = []
222 try:
223 for i, (sha, title) in enumerate(all_commits):
224 short = sha[:10]
225 if is_range:
226 label = "baseline" if i == 0 else f"{i}/{len(all_commits) - 1}"
227 else:
228 label = f"{i + 1}/{len(all_commits)}"
229 print(f"\n[{label}] {short} {title}", file=sys.stderr)
230
231 git_checkout(sha)
232
233 print(f" Building (env: {args.env})...", file=sys.stderr)
234 rc, output = build_firmware(args.env)
235
236 if rc != 0:
237 print(f" BUILD FAILED (exit {rc}) -- skipping", file=sys.stderr)
238 results.append((sha, title, None))
239 continue
240
241 used = parse_flash_used(output)
242 if used is None:
243 print(" Could not parse flash size from output -- skipping", file=sys.stderr)
244 results.append((sha, title, None))
245 continue
246
247 print(f" Flash used: {used:,} bytes", file=sys.stderr)
248 results.append((sha, title, used))
249
250 except KeyboardInterrupt:
251 print("\n[info] Interrupted -- writing partial results.", file=sys.stderr)
252 finally:
253 print(f"\n[info] Restoring '{original_ref}'...", file=sys.stderr)
254 run(["git", "checkout", original_ref], check=False)
255 if stash_needed:
256 print("[info] Restoring stashed changes...", file=sys.stderr)
257 run(["git", "stash", "pop"], check=False)
258
259 # Build result rows with deltas
260 rows = []
261 prev_size = None
262 for sha, title, used in results:
263 if used is not None and prev_size is not None:
264 delta = used - prev_size
265 else:
266 delta = ""
267 rows.append({
268 "commit": sha[:10],
269 "title": title,
270 "flash_bytes": used if used is not None else "FAILED",
271 "delta": delta,
272 })
273 if used is not None:
274 prev_size = used
275
276 fieldnames = ["commit", "title", "flash_bytes", "delta"]
277
278 if args.csv is not None:
279 if args.csv == "-":
280 write_csv(sys.stdout, rows, fieldnames)
281 else:
282 with open(args.csv, "w", newline="") as f:
283 write_csv(f, rows, fieldnames)
284 print(f"\n[done] Wrote {args.csv}", file=sys.stderr)
285 else:
286 print()
287 format_table(rows)
288
289
290if __name__ == "__main__":
291 main()