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.

at master 291 lines 9.5 kB view raw
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()