#!/usr/bin/env python3 """Animate how a codebase's architecture grew, across its git history. Walks the git history of a directory, re-runs one of the bundled importers at each sampled commit (the tree is pulled with ``git archive`` — the working copy is never touched), lays each out and exports a PNG frame, then assembles a single self-contained HTML player (frames embedded as base64, play / step controls, no external files or CDNs). Open it in any browser to watch the modules and edges appear over time. python3 timelapse.py skills/drawio-skill/scripts --importer pyimports # -> architecture-evolution.html The importer is any of the bundled graph extractors (pyimports, jsimports, goimports, rustimports, pyclasses, tfimports, k8simports, composeimports, sqlerd); it is run against the archived directory with the same positional "path" argument they all take, so point the path at the project/package/infra root the importer expects. Extra importer flags pass through via ``--importer-args`` (e.g. ``--importer-args "--group"``). Commits that touched the directory are sampled evenly (always keeping the first and last) down to ``--max-frames``; a commit where the importer finds nothing (the path did not exist yet) is skipped. Needs git, the importer's requirements, Graphviz (autolayout) and the draw.io CLI — the same tools the importers use. Usage: python3 timelapse.py [--importer NAME] [--importer-args STR] [--max-frames N] [-o out.html] [--direction TB|LR] [--keep-frames] """ import argparse import base64 import io import json import os import subprocess import sys import tarfile import tempfile HERE = os.path.dirname(os.path.abspath(__file__)) IMPORTERS = {"pyimports", "jsimports", "goimports", "rustimports", "pyclasses", "tfimports", "k8simports", "composeimports", "sqlerd"} def git(root, *args): """Run a git command in `root`; return (returncode, stdout_bytes).""" p = subprocess.run(["git", "-C", root, *args], capture_output=True) return p.returncode, p.stdout def sample_indices(total, n): """Evenly-spaced indices across range(total), always incl. first and last. For total <= n every index is kept; otherwise n indices are picked so the first (0) and last (total-1) are always present and the rest are spread uniformly between them. """ if total <= 0: return [] if total <= n or n <= 1: return list(range(total)) return sorted({round(i * (total - 1) / (n - 1)) for i in range(n)}) def history(root, subpath): """Chronological [(hash, iso_date, subject), ...] of commits touching subpath.""" code, out = git(root, "log", "--format=%H%x09%aI%x09%s", "--", subpath or ".") if code != 0: return [] rows = [] for line in out.decode("utf-8", "replace").splitlines(): parts = line.split("\t", 2) if len(parts) == 3: rows.append(tuple(parts)) return list(reversed(rows)) # oldest first def extract_tree(root, commit, subpath, dest): """Extract subpath of `commit` into dest via git archive. False if absent.""" code, tar_bytes = git(root, "archive", commit, subpath or ".") if code != 0 or not tar_bytes: return False with tarfile.open(fileobj=io.BytesIO(tar_bytes)) as tf: tf.extractall(dest) # git-authored tar; trusted return True def build_frame(importer, importer_args, work_path, direction, tmp): """Importer -> autolayout -> PNG for one commit. Returns (png_bytes, n, e) or None.""" graph_json = os.path.join(tmp, "graph.json") imp = subprocess.run( [sys.executable, os.path.join(HERE, importer + ".py"), work_path, "-o", graph_json, *importer_args], capture_output=True) if imp.returncode != 0 or not os.path.exists(graph_json): return None with open(graph_json, encoding="utf-8") as f: graph = json.loads(f.read()) if not graph.get("nodes"): return None graph["direction"] = direction with open(graph_json, "w", encoding="utf-8") as f: f.write(json.dumps(graph)) drawio = os.path.join(tmp, "frame.drawio") lay = subprocess.run( [sys.executable, os.path.join(HERE, "autolayout.py"), graph_json, "-o", drawio], capture_output=True) if lay.returncode != 0 or not os.path.exists(drawio): return None png = os.path.join(tmp, "frame.png") exp = subprocess.run(["drawio", "-x", "-f", "png", "--width", "1600", "-o", png, drawio], capture_output=True) if exp.returncode != 0 or not os.path.exists(png): return None with open(png, "rb") as f: png_data = f.read() return png_data, len(graph["nodes"]), len(graph["edges"]) def build_html(frames, title): """Self-contained HTML player for the frame list.""" data = [{"img": "data:image/png;base64," + base64.b64encode(png).decode(), "hash": h[:9], "date": d[:10], "subj": s, "n": n, "e": e} for png, h, d, s, n, e in frames] peak = max((f["n"] for f in data), default=1) or 1 payload = json.dumps(data).replace(" {title}

{title}

architecture frame
·
""" def main(): ap = argparse.ArgumentParser(description="Git-history architecture time-lapse -> HTML player.") ap.add_argument("path", help="directory to visualize (inside a git repo)") ap.add_argument("--importer", default="pyimports", help="bundled importer to run at each commit (default pyimports)") ap.add_argument("--importer-args", default="", help="extra args passed to the importer, e.g. \"--group\"") ap.add_argument("--max-frames", type=int, default=10, help="sample down to N commits") ap.add_argument("--direction", default="TB", choices=["TB", "LR"]) ap.add_argument("-o", "--output", default="architecture-evolution.html") ap.add_argument("--keep-frames", action="store_true", help="also write the PNG frames next to the HTML") args = ap.parse_args() importer = args.importer[:-3] if args.importer.endswith(".py") else args.importer if importer not in IMPORTERS: sys.exit(f"error: unknown importer {importer!r} (choose one of: " + ", ".join(sorted(IMPORTERS)) + ")") if not os.path.isdir(args.path): sys.exit(f"error: {args.path} is not a directory") code, top = git(args.path, "rev-parse", "--show-toplevel") if code != 0: sys.exit(f"error: {args.path} is not inside a git repository") root = top.decode().strip() subpath = os.path.relpath(os.path.abspath(args.path), root) if subpath == ".": subpath = "" commits = history(root, subpath) if not commits: sys.exit(f"error: no commits touch {args.path}") picked = [commits[i] for i in sample_indices(len(commits), args.max_frames)] imp_args = args.importer_args.split() frames = [] for n, (h, date, subj) in enumerate(picked, 1): sys.stderr.write(f"[{n}/{len(picked)}] {h[:9]} {subj[:50]}\n") with tempfile.TemporaryDirectory() as tmp: if not extract_tree(root, h, subpath, tmp): continue work = os.path.join(tmp, subpath) if subpath else tmp frame = build_frame(importer, imp_args, work, args.direction, tmp) if frame is None: sys.stderr.write(" (importer found nothing — skipped)\n") continue png, nn, ee = frame frames.append((png, h, date, subj, nn, ee)) if args.keep_frames: fp = f"{os.path.splitext(args.output)[0]}-frame{len(frames):02d}.png" with open(fp, "wb") as f: f.write(png) if not frames: sys.exit("error: no frames produced (importer found nothing in any commit)") title = f"Architecture evolution — {os.path.basename(os.path.abspath(args.path))}" with open(args.output, "w", encoding="utf-8") as f: f.write(build_html(frames, title)) sys.stderr.write(f"wrote {args.output} ({len(frames)} frames)\n") if __name__ == "__main__": main()