generator for git.lucas.co
git clone https://git.lucas.co/gitsite.git
generate.py (11K)
1 #!/usr/bin/env python3
2 # Static git repository browser generator for git.lucas.co.
3 # Reads bare mirrors from ~/.cache/gitsite/mirrors (created by build.sh)
4 # and writes a browsable HTML site to ~/.cache/gitsite/out.
5
6 import html
7 import os
8 import shutil
9 import subprocess
10 import sys
11 from pathlib import Path
12 from urllib.parse import quote
13
14 BASE = Path(__file__).resolve().parent
15 CACHE = Path.home() / ".cache" / "gitsite"
16 MIRRORS = CACHE / "mirrors"
17 OUT = CACHE / "out"
18
19 SITE_TITLE = "git.lucas.co"
20 HOME_URL = "https://lucas.co"
21 CLONE_BASE = "https://git.lucas.co"
22
23 MAX_DIFF_BYTES = 500_000 # truncate commit patches beyond this
24 MAX_BLOB_BYTES = 300_000 # don't render file contents beyond this
25
26
27 def read_repos():
28 repos = []
29 for line in (BASE / "repos.conf").read_text().splitlines():
30 line = line.strip()
31 if not line or line.startswith("#"):
32 continue
33 name, path, desc, mode = line.split("|")
34 repos.append({"name": name, "path": path, "desc": desc,
35 "clone": mode == "clone"})
36 return repos
37
38
39 def git(mirror, *args, binary=False):
40 r = subprocess.run(["git", "-C", str(mirror), *args], capture_output=True)
41 if r.returncode != 0:
42 raise RuntimeError(f"git {' '.join(args)} failed in {mirror}: "
43 f"{r.stderr.decode('utf-8', 'replace')}")
44 return r.stdout if binary else r.stdout.decode("utf-8", "replace")
45
46
47 def esc(s):
48 return html.escape(s, quote=True)
49
50
51 def human_size(n):
52 if n == "-": # e.g. submodule entries
53 return "-"
54 n = int(n)
55 if n < 1024:
56 return f"{n}B"
57 for unit in ("K", "M", "G"):
58 n /= 1024
59 if n < 1024 or unit == "G":
60 return f"{n:.1f}".removesuffix(".0") + unit
61
62
63 def write_page(path, title, body, site_root):
64 path.parent.mkdir(parents=True, exist_ok=True)
65 path.write_text(f"""<!DOCTYPE html>
66 <html>
67 <head>
68 <meta charset="UTF-8">
69 <meta name="viewport" content="width=device-width, initial-scale=1.0">
70 <title>{esc(title)}</title>
71 <link rel="stylesheet" href="{site_root}style.css">
72 </head>
73 <body>
74 {body}
75 </body>
76 </html>
77 """)
78
79
80 def rel(from_dir, to_dir):
81 # relative prefix ("", "../", "../../", ...) from a page dir to another dir
82 r = os.path.relpath(to_dir, from_dir)
83 return "" if r == "." else r + "/"
84
85
86 def repo_header(repo, page_dir, repo_dir, active):
87 site_root = rel(page_dir, OUT)
88 repo_root = rel(page_dir, repo_dir)
89 nav = " | ".join(
90 f'<a href="{repo_root}{href}">{label}</a>' if label != active
91 else f'<span class="active">{label}</span>'
92 for label, href in (("Log", "index.html"), ("Files", "files.html"),
93 ("Refs", "refs.html")))
94 desc = f'\n<div class="desc">{esc(repo["desc"])}</div>' if repo["desc"] else ""
95 clone = (f'\n<div class="clone">git clone {CLONE_BASE}/{repo["name"]}.git</div>'
96 if repo["clone"] else "")
97 return (f'<div class="crumbs"><a href="{site_root}index.html">{SITE_TITLE}</a>'
98 f' / <a href="{repo_root}index.html">{esc(repo["name"])}</a></div>{desc}{clone}\n'
99 f'<div class="nav">{nav}</div>\n<hr>\n')
100
101
102 def fmt_diff(patch):
103 out = []
104 for line in patch.split("\n"):
105 e = esc(line)
106 if line.startswith("diff --git"):
107 out.append(f'<span class="df">{e}</span>')
108 elif line.startswith("@@"):
109 out.append(f'<span class="dh">{e}</span>')
110 elif line.startswith("+") and not line.startswith("+++"):
111 out.append(f'<span class="di">{e}</span>')
112 elif line.startswith("-") and not line.startswith("---"):
113 out.append(f'<span class="dd">{e}</span>')
114 else:
115 out.append(e)
116 return "\n".join(out)
117
118
119 def gen_commit_pages(repo, mirror, repo_dir, commits):
120 page_dir = repo_dir / "commit"
121 for h, at, author, subject in commits:
122 meta = git(mirror, "show", "--no-patch",
123 "--format=%H%x1f%P%x1f%an <%ae>%x1f%ad%x1f%B",
124 "--date=format:%Y-%m-%d %H:%M", h)
125 full, parents, who, date, msg = meta.split("\x1f", 4)
126 patch_b = git(mirror, "show", "--format=", "--stat", "--patch",
127 "--no-color", h, binary=True)
128 truncated = len(patch_b) > MAX_DIFF_BYTES
129 patch = patch_b[:MAX_DIFF_BYTES].decode("utf-8", "replace")
130 parent_html = " ".join(
131 f'<a href="{p}.html">{p[:10]}</a>' for p in parents.split() if p)
132 body = repo_header(repo, page_dir, repo_dir, None)
133 body += '<table class="meta">\n'
134 body += f'<tr><td>commit</td><td>{full}</td></tr>\n'
135 if parent_html:
136 body += f'<tr><td>parent</td><td>{parent_html}</td></tr>\n'
137 body += f'<tr><td>author</td><td>{esc(who)}</td></tr>\n'
138 body += f'<tr><td>date</td><td>{date}</td></tr>\n'
139 body += '</table>\n'
140 body += f'<pre class="msg">{esc(msg.strip())}</pre>\n<hr>\n'
141 body += f'<pre class="diff">{fmt_diff(patch)}</pre>\n'
142 if truncated:
143 body += '<div class="notice">diff truncated</div>\n'
144 write_page(page_dir / f"{full}.html",
145 f'{repo["name"]}: {subject}', body, rel(page_dir, OUT))
146
147
148 def gen_log(repo, mirror, repo_dir, commits):
149 body = repo_header(repo, repo_dir, repo_dir, "Log")
150 body += '<table class="list">\n<tr><td>Date</td><td>Message</td><td>Author</td></tr>\n'
151 for h, at, author, subject in commits:
152 body += (f'<tr><td>{at}</td>'
153 f'<td><a href="commit/{h}.html">{esc(subject)}</a></td>'
154 f'<td>{esc(author)}</td></tr>\n')
155 body += '</table>\n'
156 write_page(repo_dir / "index.html", repo["name"], body, rel(repo_dir, OUT))
157
158
159 def gen_files(repo, mirror, repo_dir):
160 body = repo_header(repo, repo_dir, repo_dir, "Files")
161 body += '<table class="list">\n<tr><td>Mode</td><td>Name</td><td>Size</td></tr>\n'
162 entries = []
163 for line in git(mirror, "ls-tree", "-r", "-l", "HEAD").splitlines():
164 info, path = line.split("\t", 1)
165 mode, otype, _h, size = info.split()
166 entries.append((mode, otype, size, path))
167 href = quote(f"file/{path}.html")
168 body += (f'<tr><td class="mode">{mode}</td>'
169 f'<td><a href="{href}">{esc(path)}</a></td>'
170 f'<td class="size">{human_size(size)}</td></tr>\n')
171 body += '</table>\n'
172 write_page(repo_dir / "files.html", f'{repo["name"]} files', body,
173 rel(repo_dir, OUT))
174 return entries
175
176
177 def gen_blob_pages(repo, mirror, repo_dir, entries):
178 for mode, otype, size, path in entries:
179 out_path = repo_dir / "file" / (path + ".html")
180 page_dir = out_path.parent
181 body = repo_header(repo, page_dir, repo_dir, None)
182 body += f'<div class="path">{esc(path)} ({human_size(size)})</div>\n<hr>\n'
183 if otype != "blob":
184 body += '<div class="notice">not a regular file</div>\n'
185 else:
186 content = git(mirror, "cat-file", "blob", f"HEAD:{path}", binary=True)
187 if b"\0" in content[:8000]:
188 body += '<div class="notice">binary file</div>\n'
189 elif len(content) > MAX_BLOB_BYTES:
190 body += '<div class="notice">file too large to display</div>\n'
191 else:
192 text = content.decode("utf-8", "replace")
193 lines = text.split("\n")
194 if lines and lines[-1] == "":
195 lines.pop()
196 w = len(str(len(lines)))
197 rows = "\n".join(
198 f'<a class="ln" id="l{i}" href="#l{i}">{i:>{w}}</a> {esc(l)}'
199 for i, l in enumerate(lines, 1))
200 body += f'<pre class="blob">{rows}</pre>\n'
201 write_page(out_path, f'{repo["name"]}: {path}', body, rel(page_dir, OUT))
202
203
204 def gen_refs(repo, mirror, repo_dir):
205 body = repo_header(repo, repo_dir, repo_dir, "Refs")
206 for title, pattern in (("Branches", "refs/heads"), ("Tags", "refs/tags")):
207 refs = git(mirror, "for-each-ref", "--sort=-creatordate",
208 "--format=%(refname:short)%1f%(objectname:short)%1f%(creatordate:short)",
209 pattern).splitlines()
210 if not refs and pattern == "refs/tags":
211 continue
212 body += f'<div class="section">{title}</div>\n<table class="list">\n'
213 for r in refs:
214 name, obj, date = r.split("\x1f")
215 body += f'<tr><td>{esc(name)}</td><td>{obj}</td><td>{date}</td></tr>\n'
216 body += '</table>\n'
217 write_page(repo_dir / "refs.html", f'{repo["name"]} refs', body,
218 rel(repo_dir, OUT))
219
220
221 def gen_404():
222 # a real 404.html switches Pages out of SPA-fallback mode; without it,
223 # unknown paths (e.g. git probing loose objects) get index.html with a 200
224 body = (f'<div class="crumbs"><a href="index.html">{SITE_TITLE}</a></div>\n'
225 '<hr>\n<div class="notice">not found</div>\n')
226 write_page(OUT / "404.html", f"{SITE_TITLE}: not found", body, "")
227
228
229 def gen_headers(repos):
230 # keep Cloudflare from recompressing/transforming git transport files
231 rules = ""
232 for repo in repos:
233 if repo["clone"]:
234 rules += (f'/{repo["name"]}.git/*\n'
235 " Cache-Control: no-transform\n"
236 " Content-Type: application/octet-stream\n")
237 (OUT / "_headers").write_text(rules)
238
239
240 def gen_index(repos):
241 body = (f'<div class="crumbs">{SITE_TITLE}</div>\n'
242 f'<div class="desc"><a href="{HOME_URL}">Lucas Galante</a>\'s projects</div>\n<hr>\n')
243 body += '<table class="list">\n<tr><td>Name</td><td>Description</td><td>Last commit</td></tr>\n'
244 for repo in repos:
245 mirror = MIRRORS / f'{repo["name"]}.git'
246 last = git(mirror, "log", "-1", "--format=%as", "HEAD").strip()
247 body += (f'<tr><td><a href="{quote(repo["name"])}/index.html">{esc(repo["name"])}</a></td>'
248 f'<td>{esc(repo["desc"])}</td><td>{last}</td></tr>\n')
249 body += '</table>\n'
250 write_page(OUT / "index.html", SITE_TITLE, body, "")
251
252
253 def main():
254 repos = read_repos()
255 if OUT.exists():
256 shutil.rmtree(OUT)
257 OUT.mkdir(parents=True)
258 shutil.copy(BASE / "style.css", OUT / "style.css")
259 font = BASE / "font"
260 if font.is_dir():
261 shutil.copytree(font, OUT / "font")
262
263 for repo in repos:
264 mirror = MIRRORS / f'{repo["name"]}.git'
265 if not mirror.is_dir():
266 sys.exit(f"missing mirror {mirror}; run build.sh")
267 repo_dir = OUT / repo["name"]
268 commits = []
269 log = git(mirror, "log", "--format=%H%x1f%as%x1f%an%x1f%s", "HEAD")
270 for line in log.splitlines():
271 h, at, author, subject = line.split("\x1f")
272 commits.append((h, at, author, subject))
273 gen_log(repo, mirror, repo_dir, commits)
274 gen_commit_pages(repo, mirror, repo_dir, commits)
275 entries = gen_files(repo, mirror, repo_dir)
276 gen_blob_pages(repo, mirror, repo_dir, entries)
277 gen_refs(repo, mirror, repo_dir)
278 print(f'{repo["name"]}: {len(commits)} commits, {len(entries)} files')
279
280 gen_404()
281 gen_headers(repos)
282 gen_index(repos)
283 print(f"wrote {OUT}")
284
285
286 if __name__ == "__main__":
287 main()