#!/usr/bin/env python3 """ Build firmware at selected commits and report flash usage. Two modes (mutually exclusive, one required): --range START END Walk every commit from START (exclusive baseline) to END (inclusive), oldest-first. START is also built so the first delta can be computed. Both refs must lie on the same ancestry path (i.e. one branch). --commits REF [...] Build each REF in the order given. Refs may be SHAs, branch names, tags, or relative refs like HEAD~3. They can come from different branches. Common options: --env ENV PlatformIO build environment (default: "default") --csv [FILE] Output as CSV. Without FILE, writes to stdout. Output is a human-readable table by default. Use --csv for machine-readable output. Examples: python3 scripts/firmware_size_history.py --range HEAD~5 HEAD python3 scripts/firmware_size_history.py --range abc1234 def5678 --env gh_release --csv sizes.csv python3 scripts/firmware_size_history.py --commits main feature/new-parser python3 scripts/firmware_size_history.py --commits abc1234 def5678 ghi9012 --csv """ import argparse import csv import re import subprocess import sys FLASH_RE = re.compile( r"Flash:.*?(\d+)\s+bytes\s+from\s+(\d+)\s+bytes" ) BOX_CHAR = "\u2500" def run(cmd, capture=True, check=True): result = subprocess.run( cmd, capture_output=capture, text=True, check=check ) return result def resolve_ref(ref): """Resolve a git ref to (full_sha, title), or sys.exit with a message.""" r = run(["git", "rev-parse", "--verify", ref], check=False) if r.returncode != 0: print(f"[error] Could not resolve ref '{ref}'", file=sys.stderr) sys.exit(1) sha = r.stdout.strip() title = run(["git", "log", "-1", "--format=%s", sha]).stdout.strip() return sha, title def git_current_ref(): """Return the current branch name, or the detached commit hash.""" r = run(["git", "symbolic-ref", "--short", "HEAD"], check=False) if r.returncode == 0: return r.stdout.strip() return run(["git", "rev-parse", "HEAD"]).stdout.strip() def git_commit_list(start, end): """Return list of (hash, title) from start (exclusive) to end (inclusive), oldest first.""" r = run([ "git", "log", "--reverse", "--format=%H %s", f"{start}..{end}", ]) commits = [] for line in r.stdout.strip().splitlines(): if not line: continue sha, title = line.split(" ", 1) commits.append((sha, title)) return commits def git_checkout(ref): run(["git", "checkout", "--detach", ref], check=True) def build_firmware(env): """Run pio build and return the raw combined stdout+stderr.""" result = subprocess.run( ["pio", "run", "-e", env], capture_output=True, text=True, check=False ) return result.returncode, result.stdout + "\n" + result.stderr def parse_flash_used(output): """Extract used-bytes integer from PlatformIO output, or None.""" m = FLASH_RE.search(output) if m: return int(m.group(1)) return None def write_csv(out, rows, fieldnames): """Write rows as CSV to a file-like object.""" writer = csv.DictWriter(out, fieldnames=fieldnames) writer.writeheader() writer.writerows(rows) def format_table(rows): """Print rows as an aligned human-readable table to stdout.""" COL_COMMIT = 10 COL_FLASH = 11 COL_DELTA = 7 def fmt_flash(val): if val == "FAILED": return "FAILED" return f"{val:,}" def fmt_delta(val): if val == "" or val is None: return "" return f"{val:+,}" header = ( f"{'Commit':<{COL_COMMIT}} " f"{'Flash':>{COL_FLASH}} " f"{'Delta':>{COL_DELTA}} " f"Title" ) sep = ( f"{BOX_CHAR * COL_COMMIT} " f"{BOX_CHAR * COL_FLASH} " f"{BOX_CHAR * COL_DELTA} " f"{BOX_CHAR * 40}" ) print(header) print(sep) for row in rows: flash_str = fmt_flash(row["flash_bytes"]) delta_str = fmt_delta(row["delta"]) print( f"{row['commit']:<{COL_COMMIT}} " f"{flash_str:>{COL_FLASH}} " f"{delta_str:>{COL_DELTA}} " f"{row['title']}" ) def build_commits_from_range(start, end): """Validate a range and return (all_commits, description) for the build loop.""" start_sha, start_title = resolve_ref(start) resolve_ref(end) commits = git_commit_list(start, end) if not commits: print(f"[error] No commits found in range {start}..{end}", file=sys.stderr) sys.exit(1) all_commits = [(start_sha, start_title)] + commits desc = f"{len(all_commits)} commits (1 baseline + {len(commits)} in range)" return all_commits, desc def build_commits_from_list(refs): """Resolve each ref and return (all_commits, description) for the build loop.""" all_commits = [resolve_ref(ref) for ref in refs] desc = f"{len(all_commits)} commit{'s' if len(all_commits) != 1 else ''}" return all_commits, desc def main(): parser = argparse.ArgumentParser( description="Measure firmware flash size across git commits.", epilog=( "Range mode walks every commit between START and END (one branch). " "List mode builds specific refs that may come from different branches." ), ) mode = parser.add_mutually_exclusive_group(required=True) mode.add_argument( "--range", nargs=2, metavar=("START", "END"), help="Older commit (exclusive baseline) and newer commit (inclusive)", ) mode.add_argument( "--commits", nargs="+", metavar="REF", help="One or more git refs to build (SHAs, branches, tags, HEAD~N, ...)", ) parser.add_argument("--env", default="default", help="PlatformIO environment (default: 'default')") parser.add_argument( "--csv", nargs="?", const="-", default=None, metavar="FILE", help="Output as CSV (default: stdout, or specify FILE)", ) args = parser.parse_args() # Validate refs before touching the working tree so a bad ref never # leaves uncommitted changes stranded in the stash. if args.range: all_commits, desc = build_commits_from_range(args.range[0], args.range[1]) is_range = True else: all_commits, desc = build_commits_from_list(args.commits) is_range = False original_ref = git_current_ref() print(f"[info] Will restore to '{original_ref}' when finished.", file=sys.stderr) stash_needed = False status = run(["git", "status", "--porcelain"]).stdout.strip() if status: print("[info] Stashing uncommitted changes...", file=sys.stderr) run(["git", "stash", "push", "-m", "firmware_size_history auto-stash"]) stash_needed = True print(f"[info] Building {desc}...", file=sys.stderr) results = [] try: for i, (sha, title) in enumerate(all_commits): short = sha[:10] if is_range: label = "baseline" if i == 0 else f"{i}/{len(all_commits) - 1}" else: label = f"{i + 1}/{len(all_commits)}" print(f"\n[{label}] {short} {title}", file=sys.stderr) git_checkout(sha) print(f" Building (env: {args.env})...", file=sys.stderr) rc, output = build_firmware(args.env) if rc != 0: print(f" BUILD FAILED (exit {rc}) -- skipping", file=sys.stderr) results.append((sha, title, None)) continue used = parse_flash_used(output) if used is None: print(" Could not parse flash size from output -- skipping", file=sys.stderr) results.append((sha, title, None)) continue print(f" Flash used: {used:,} bytes", file=sys.stderr) results.append((sha, title, used)) except KeyboardInterrupt: print("\n[info] Interrupted -- writing partial results.", file=sys.stderr) finally: print(f"\n[info] Restoring '{original_ref}'...", file=sys.stderr) run(["git", "checkout", original_ref], check=False) if stash_needed: print("[info] Restoring stashed changes...", file=sys.stderr) run(["git", "stash", "pop"], check=False) # Build result rows with deltas rows = [] prev_size = None for sha, title, used in results: if used is not None and prev_size is not None: delta = used - prev_size else: delta = "" rows.append({ "commit": sha[:10], "title": title, "flash_bytes": used if used is not None else "FAILED", "delta": delta, }) if used is not None: prev_size = used fieldnames = ["commit", "title", "flash_bytes", "delta"] if args.csv is not None: if args.csv == "-": write_csv(sys.stdout, rows, fieldnames) else: with open(args.csv, "w", newline="") as f: write_csv(f, rows, fieldnames) print(f"\n[done] Wrote {args.csv}", file=sys.stderr) else: print() format_table(rows) if __name__ == "__main__": main()