A fork of https://github.com/crosspoint-reader/crosspoint-reader
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

chore: add firmware size history script (#1235)

## Summary

**What is the goal of this PR?**

- Adds `scripts/firmware_size_history.py`, a developer tool that builds
firmware at selected git commits and reports flash usage with deltas
between them.
- Supports two input modes: `--range START END` to walk every commit in
a range, or `--commits REF [REF ...]` to compare specific refs (which
can span branches).
- Defaults to a human-readable aligned table; pass `--csv` for
machine-readable output to stdout or `--csv FILE` to write to a file.

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**YES, fully written by
AI**_

authored by

Zach Nelson and committed by
GitHub
620835a6 3cc8e272

+291
+291
scripts/firmware_size_history.py
··· 1 + #!/usr/bin/env python3 2 + """ 3 + Build firmware at selected commits and report flash usage. 4 + 5 + Two 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 + 16 + Common options: 17 + --env ENV PlatformIO build environment (default: "default") 18 + --csv [FILE] Output as CSV. Without FILE, writes to stdout. 19 + 20 + Output is a human-readable table by default. Use --csv for machine-readable 21 + output. 22 + 23 + Examples: 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 + 30 + import argparse 31 + import csv 32 + import re 33 + import subprocess 34 + import sys 35 + 36 + FLASH_RE = re.compile( 37 + r"Flash:.*?(\d+)\s+bytes\s+from\s+(\d+)\s+bytes" 38 + ) 39 + BOX_CHAR = "\u2500" 40 + 41 + 42 + def 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 + 49 + def 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 + 60 + def 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 + 68 + def 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 + 83 + def git_checkout(ref): 84 + run(["git", "checkout", "--detach", ref], check=True) 85 + 86 + 87 + def 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 + 96 + def 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 + 104 + def 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 + 111 + def 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 + 152 + def 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 + 167 + def 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 + 174 + def 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 + 290 + if __name__ == "__main__": 291 + main()