Files

258 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <dir> [--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("</", "<\\/")
return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{title}</title><style>
:root{{color-scheme:light dark}}
*{{box-sizing:border-box}}
body{{margin:0;font:14px/1.5 system-ui,-apple-system,Segoe UI,sans-serif;
background:#f6f7f9;color:#1a1a1a}}
@media(prefers-color-scheme:dark){{body{{background:#15171a;color:#e8e8e8}}}}
header{{padding:16px 20px 4px}}h1{{margin:0;font-size:17px;font-weight:600}}
main{{max-width:1100px;margin:0 auto;padding:8px 16px 28px}}
#stage{{background:#fff;border:1px solid #0001;border-radius:10px;
min-height:60vh;display:flex;align-items:center;justify-content:center;padding:12px}}
@media(prefers-color-scheme:dark){{#stage{{background:#1e2226;border-color:#fff2}}}}
#stage img{{max-width:100%;max-height:74vh;object-fit:contain}}
.cap{{display:flex;gap:14px;flex-wrap:wrap;align-items:baseline;
padding:12px 4px 6px;color:#556;font-size:13px}}
@media(prefers-color-scheme:dark){{.cap{{color:#9aa}}}}
.cap b{{color:inherit;font-weight:600}}.cap .subj{{color:#1a1a1a}}
@media(prefers-color-scheme:dark){{.cap .subj{{color:#e8e8e8}}}}
.bar{{height:6px;border-radius:3px;background:#0d99ff;transition:width .3s}}
.barwrap{{height:6px;background:#0001;border-radius:3px;margin:2px 4px 12px}}
.ctl{{display:flex;gap:10px;align-items:center;padding:4px}}
button{{font:inherit;padding:6px 12px;border:1px solid #0002;border-radius:8px;
background:#fff;cursor:pointer;color:inherit}}
@media(prefers-color-scheme:dark){{button{{background:#262b31;border-color:#fff2}}}}
button:hover{{border-color:#0d99ff}}
input[type=range]{{flex:1;accent-color:#0d99ff}}
</style></head><body>
<header><h1>{title}</h1></header>
<main>
<div id="stage"><img id="img" alt="architecture frame"></div>
<div class="cap">
<span><b id="idx"></b></span>
<span><b id="hash"></b> · <span id="date"></span></span>
<span class="subj" id="subj"></span>
<span id="counts"></span>
</div>
<div class="barwrap"><div class="bar" id="bar"></div></div>
<div class="ctl">
<button id="prev"> Prev</button>
<button id="play">▶ Play</button>
<button id="next">Next </button>
<input type="range" id="scrub" min="0" value="0">
</div>
</main>
<script>
const F={payload},PEAK={peak};
let i=0,timer=null;
const $=id=>document.getElementById(id);
$("scrub").max=F.length-1;
function show(k){{
i=(k+F.length)%F.length;const f=F[i];
$("img").src=f.img;$("idx").textContent=`Frame ${{i+1}} / ${{F.length}}`;
$("hash").textContent=f.hash;$("date").textContent=f.date;$("subj").textContent=f.subj;
$("counts").textContent=`${{f.n}} nodes · ${{f.e}} edges`;
$("bar").style.width=(6+94*f.n/PEAK)+"%";$("scrub").value=i;
}}
function stop(){{clearInterval(timer);timer=null;$("play").textContent="▶ Play";}}
$("prev").onclick=()=>{{stop();show(i-1);}};
$("next").onclick=()=>{{stop();show(i+1);}};
$("scrub").oninput=e=>{{stop();show(+e.target.value);}};
$("play").onclick=()=>{{
if(timer){{stop();return;}}
$("play").textContent="⏸ Pause";
timer=setInterval(()=>{{if(i>=F.length-1){{show(0);}}else{{show(i+1);}}}},900);
}};
show(0);
</script></body></html>"""
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()