Files
crosspoint-reader-mod/scripts/firmware_size_history.py

292 lines
9.3 KiB
Python
Raw Normal View History

#!/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()