render markdown, add test site
45 files changed,  +3972, -0
A .gitignore
+6, -0
1@@ -0,0 +1,6 @@
2+pgit
3+*.swp
4+*.log
5+public/
6+testdata.site/
7+testdata.repo/
A .golangci.yml
+12, -0
 1@@ -0,0 +1,12 @@
 2+version: "2"
 3+
 4+linters:
 5+  enable:
 6+    - godot
 7+
 8+formatters:
 9+  enable:
10+    - goimports
11+
12+run:
13+  timeout: 10m
A Dockerfile
+21, -0
 1@@ -0,0 +1,21 @@
 2+FROM golang:1.24 AS builder
 3+
 4+WORKDIR /app
 5+
 6+COPY go.mod go.sum ./
 7+RUN go mod download && go mod verify
 8+
 9+COPY . /app
10+
11+RUN go build -v -o pgit main.go
12+
13+FROM debian:12
14+WORKDIR /app
15+
16+RUN apt-get update && apt-get install -y git
17+# ignore git warning "detected dubious ownership in repository"
18+RUN git config --global safe.directory '*'
19+
20+COPY --from=builder /app/pgit /usr/bin
21+
22+CMD ["pgit"]
A LICENSE
+21, -0
 1@@ -0,0 +1,21 @@
 2+MIT License
 3+
 4+Copyright (c) 2022 pico.sh LLC
 5+
 6+Permission is hereby granted, free of charge, to any person obtaining a copy
 7+of this software and associated documentation files (the "Software"), to deal
 8+in the Software without restriction, including without limitation the rights
 9+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+copies of the Software, and to permit persons to whom the Software is
11+furnished to do so, subject to the following conditions:
12+
13+The above copyright notice and this permission notice shall be included in all
14+copies or substantial portions of the Software.
15+
16+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+SOFTWARE.
A Makefile
+56, -0
 1@@ -0,0 +1,56 @@
 2+REV=$(shell git rev-parse --short HEAD)
 3+PROJECT="git-pgit-$(REV)"
 4+
 5+smol:
 6+	curl https://pico.sh/smol.css -o ./static/smol.css
 7+.PHONY: smol
 8+
 9+clean:
10+	rm -rf ./public
11+.PHONY: clean
12+
13+build:
14+	go build -o pgit ./main.go
15+.PHONY: build
16+
17+img:
18+	docker build -t neurosnap/pgit:latest .
19+.PHONY: img
20+
21+fmt:
22+	go fmt ./...
23+.PHONY: fmt
24+
25+lint:
26+	golangci-lint run
27+.PHONY: lint
28+
29+test:
30+	go test ./...
31+.PHONY: test
32+
33+test-site:
34+	mkdir -p testdata.site
35+	go run main.go \
36+		--repo ./testdata \
37+		--out testdata.site \
38+		--label testdata \
39+		--desc "pgit testing site" \
40+		--theme "dracula" \
41+		--revs main
42+.PHONY: test-site
43+
44+static: build clean
45+	./pgit \
46+		--out ./public \
47+		--label pgit \
48+		--desc "static site generator for git" \
49+		--clone-url "https://github.com/picosh/pgit.git" \
50+		--home-url "https://git.erock.io" \
51+		--theme "dracula" \
52+		--revs main
53+.PHONY:
54+
55+dev: static
56+	rsync -rv --delete ./public/ pgs.sh:/git-pgit-local/
57+.PHONY: dev
A README.md
+70, -0
 1@@ -0,0 +1,70 @@
 2+# pgit
 3+
 4+A static site generator for git.
 5+
 6+This golang binary will generate a commit log, files, and references based on a
 7+git repository and the provided revisions.
 8+
 9+It will only generate a commit log and files for the provided revisions.
10+
11+## usage
12+
13+```bash
14+make build
15+```
16+
17+```bash
18+./pgit --revs main --label pico --out ./public
19+```
20+
21+To learn more about the options run:
22+
23+```bash
24+./pgit --help
25+```
26+
27+## themes
28+
29+We support all [chroma](https://xyproto.github.io/splash/docs/all.html) themes.
30+We do our best to adapt the theme of the entire site to match the chroma syntax
31+highlighting theme. This is a "closet approximation" as we are not testing every
32+single theme.
33+
34+```bash
35+./pgit --revs main --label pico --out ./public --theme onedark
36+```
37+
38+The default theme is `dracula`. If you want to change the colors for your site,
39+we generate a `vars.css` file that you are welcome to overwrite before
40+deploying, it will _not_ change the syntax highlighting colors, only the main
41+site colors.
42+
43+## with multiple repos
44+
45+`--root-relative` sets the prefix for all links (default: `/`). This makes it so
46+you can run multiple repos and have them all live inside the same static site.
47+
48+```bash
49+pgit \
50+  --out ./public/pico \
51+  --home-url "https://git.erock.io" \
52+  --revs main \
53+  --repo ~/pico \
54+  --root-relative "/pico/"
55+
56+pgit \
57+  --out ./public/starfx \
58+  --home-url "https://git.erock.io" \
59+  --revs main \
60+  --repo ~/starfx \
61+  --root-relative "/starfx/"
62+
63+echo '<html><body><a href="/pico">pico</a><a href="/starfx">starfx</a></body></html>' > ./public/index.html
64+
65+rsync -rv ./public/ pgs.sh:/git
66+```
67+
68+## inspiration
69+
70+This project was heavily inspired by
71+[stagit](https://codemadness.org/stagit.html)
A devbox.json
+23, -0
 1@@ -0,0 +1,23 @@
 2+{
 3+  "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.17.1/.schema/devbox.schema.json",
 4+  "packages": [
 5+    "go@latest",
 6+    "gnumake@latest",
 7+    "coreutils@latest",
 8+    "bash@latest",
 9+    "ripgrep@latest",
10+    "nodejs@latest",
11+    "yarn@latest",
12+    "git@latest"
13+  ],
14+  "shell": {
15+    "init_hook": [
16+      "echo 'Welcome to devbox!' > /dev/null"
17+    ],
18+    "scripts": {
19+      "test": [
20+        "echo \"Error: no test specified\" && exit 1"
21+      ]
22+    }
23+  }
24+}
A devbox.lock
+578, -0
  1@@ -0,0 +1,578 @@
  2+{
  3+  "lockfile_version": "1",
  4+  "packages": {
  5+    "bash@latest": {
  6+      "last_modified": "2026-03-30T07:26:21Z",
  7+      "resolved": "github:NixOS/nixpkgs/15c6719d8c604779cf59e03c245ea61d3d7ab69b#bash",
  8+      "source": "devbox-search",
  9+      "version": "5.3p9",
 10+      "systems": {
 11+        "aarch64-darwin": {
 12+          "outputs": [
 13+            {
 14+              "name": "out",
 15+              "path": "/nix/store/my9bsdsfxcaxkb400i4xvvh1ahb8pybs-bash-interactive-5.3p9",
 16+              "default": true
 17+            },
 18+            {
 19+              "name": "man",
 20+              "path": "/nix/store/5nwbrxj440mxkv8sqzy3d9xsfpswhkkx-bash-interactive-5.3p9-man",
 21+              "default": true
 22+            },
 23+            {
 24+              "name": "doc",
 25+              "path": "/nix/store/p8v2kq7q82l8cz5axc9lvyj2klib1799-bash-interactive-5.3p9-doc"
 26+            },
 27+            {
 28+              "name": "info",
 29+              "path": "/nix/store/bqh3ll20jibzdrc42lclk29k144fanak-bash-interactive-5.3p9-info"
 30+            },
 31+            {
 32+              "name": "dev",
 33+              "path": "/nix/store/047i8vx61kv70j0xahh65x1p0gs4bzp5-bash-interactive-5.3p9-dev"
 34+            }
 35+          ],
 36+          "store_path": "/nix/store/my9bsdsfxcaxkb400i4xvvh1ahb8pybs-bash-interactive-5.3p9"
 37+        },
 38+        "aarch64-linux": {
 39+          "outputs": [
 40+            {
 41+              "name": "out",
 42+              "path": "/nix/store/f6lsdzsgbh5mxaaa91gykyi8mqmlzpr2-bash-interactive-5.3p9",
 43+              "default": true
 44+            },
 45+            {
 46+              "name": "man",
 47+              "path": "/nix/store/87ljrnbjn8w6iqf3bzirh6wd7lpmhvzp-bash-interactive-5.3p9-man",
 48+              "default": true
 49+            },
 50+            {
 51+              "name": "info",
 52+              "path": "/nix/store/cad8ahawmbf12gvh0bq7sf9rjjwbfzg9-bash-interactive-5.3p9-info"
 53+            },
 54+            {
 55+              "name": "debug",
 56+              "path": "/nix/store/zyi5m4r7wma9vvvfzg7r99avh8sxg9m1-bash-interactive-5.3p9-debug"
 57+            },
 58+            {
 59+              "name": "dev",
 60+              "path": "/nix/store/hkmlf3zy6brfn3xr3magif6c54ln3z4c-bash-interactive-5.3p9-dev"
 61+            },
 62+            {
 63+              "name": "doc",
 64+              "path": "/nix/store/l7bcjyprsmzdnrimjg8al47wsr4vsy6q-bash-interactive-5.3p9-doc"
 65+            }
 66+          ],
 67+          "store_path": "/nix/store/f6lsdzsgbh5mxaaa91gykyi8mqmlzpr2-bash-interactive-5.3p9"
 68+        },
 69+        "x86_64-darwin": {
 70+          "outputs": [
 71+            {
 72+              "name": "out",
 73+              "path": "/nix/store/hzc40jxl7zhc1cikxri178a4w6f4fzd6-bash-interactive-5.3p9",
 74+              "default": true
 75+            },
 76+            {
 77+              "name": "man",
 78+              "path": "/nix/store/8lp42ghh8l89v5kj6q5asbfdskssgcxn-bash-interactive-5.3p9-man",
 79+              "default": true
 80+            },
 81+            {
 82+              "name": "dev",
 83+              "path": "/nix/store/jfiwg11dqs0vzg45s58kkabjm0rm8d0c-bash-interactive-5.3p9-dev"
 84+            },
 85+            {
 86+              "name": "doc",
 87+              "path": "/nix/store/rlz86kfy3jxfi7ap587rhrm9ynbw2kvc-bash-interactive-5.3p9-doc"
 88+            },
 89+            {
 90+              "name": "info",
 91+              "path": "/nix/store/d9y32zx4cxwm3h20c0zrzsabjmws3z0m-bash-interactive-5.3p9-info"
 92+            }
 93+          ],
 94+          "store_path": "/nix/store/hzc40jxl7zhc1cikxri178a4w6f4fzd6-bash-interactive-5.3p9"
 95+        },
 96+        "x86_64-linux": {
 97+          "outputs": [
 98+            {
 99+              "name": "out",
100+              "path": "/nix/store/sfvyavxai6qvzmv9p9x6mp4wwdz4v41m-bash-interactive-5.3p9",
101+              "default": true
102+            },
103+            {
104+              "name": "man",
105+              "path": "/nix/store/lw0v8hggdjsqs9zpwwrxajcc4rbsmlfq-bash-interactive-5.3p9-man",
106+              "default": true
107+            },
108+            {
109+              "name": "info",
110+              "path": "/nix/store/p9lkzmrvl0wqjs4mjv87h5lqcypgrzbp-bash-interactive-5.3p9-info"
111+            },
112+            {
113+              "name": "debug",
114+              "path": "/nix/store/h979dcfkxhswbsdqcwqbzynaqnz1n5a0-bash-interactive-5.3p9-debug"
115+            },
116+            {
117+              "name": "dev",
118+              "path": "/nix/store/832yrsfhq3z41zn9rqsvv0cv22mblv4c-bash-interactive-5.3p9-dev"
119+            },
120+            {
121+              "name": "doc",
122+              "path": "/nix/store/fy5pa2zv8g7l3v0nn6rpwib8nl4whdx1-bash-interactive-5.3p9-doc"
123+            }
124+          ],
125+          "store_path": "/nix/store/sfvyavxai6qvzmv9p9x6mp4wwdz4v41m-bash-interactive-5.3p9"
126+        }
127+      }
128+    },
129+    "coreutils@latest": {
130+      "last_modified": "2026-03-21T07:29:51Z",
131+      "resolved": "github:NixOS/nixpkgs/09061f748ee21f68a089cd5d91ec1859cd93d0be#coreutils",
132+      "source": "devbox-search",
133+      "version": "9.10",
134+      "systems": {
135+        "aarch64-darwin": {
136+          "outputs": [
137+            {
138+              "name": "out",
139+              "path": "/nix/store/akih5l2yxpzqyh63xvyc6zsxl7kl2x4v-coreutils-9.10",
140+              "default": true
141+            },
142+            {
143+              "name": "info",
144+              "path": "/nix/store/rqr62g2a1dl14qg090lixy4kyalamxnc-coreutils-9.10-info"
145+            }
146+          ],
147+          "store_path": "/nix/store/akih5l2yxpzqyh63xvyc6zsxl7kl2x4v-coreutils-9.10"
148+        },
149+        "aarch64-linux": {
150+          "outputs": [
151+            {
152+              "name": "out",
153+              "path": "/nix/store/f03gf7yy36rlr9n1wkblvikq12a3hg6c-coreutils-9.10",
154+              "default": true
155+            },
156+            {
157+              "name": "debug",
158+              "path": "/nix/store/l7pb1mavzin4hmwpp87f6xisfprrgnr2-coreutils-9.10-debug"
159+            },
160+            {
161+              "name": "info",
162+              "path": "/nix/store/802yhcvnd2kp712af4v48klcxqzjgdkp-coreutils-9.10-info"
163+            }
164+          ],
165+          "store_path": "/nix/store/f03gf7yy36rlr9n1wkblvikq12a3hg6c-coreutils-9.10"
166+        },
167+        "x86_64-darwin": {
168+          "outputs": [
169+            {
170+              "name": "out",
171+              "path": "/nix/store/33dari5qaqpza7z0yhyzrjg85xmclg8c-coreutils-9.10",
172+              "default": true
173+            },
174+            {
175+              "name": "info",
176+              "path": "/nix/store/mybh7m3jhp3hzp83hsz8aj6w7wr49hxv-coreutils-9.10-info"
177+            }
178+          ],
179+          "store_path": "/nix/store/33dari5qaqpza7z0yhyzrjg85xmclg8c-coreutils-9.10"
180+        },
181+        "x86_64-linux": {
182+          "outputs": [
183+            {
184+              "name": "out",
185+              "path": "/nix/store/74sind1d6vf2bfwd7yklg8chsvzqxmmq-coreutils-9.10",
186+              "default": true
187+            },
188+            {
189+              "name": "debug",
190+              "path": "/nix/store/hm1z5hlgc4p99s3vng7g69cqgdn1j93h-coreutils-9.10-debug"
191+            },
192+            {
193+              "name": "info",
194+              "path": "/nix/store/c5dpvsjmin1cx3ma6jizdzb26bx2avdl-coreutils-9.10-info"
195+            }
196+          ],
197+          "store_path": "/nix/store/74sind1d6vf2bfwd7yklg8chsvzqxmmq-coreutils-9.10"
198+        }
199+      }
200+    },
201+    "git@latest": {
202+      "last_modified": "2026-03-21T07:29:51Z",
203+      "resolved": "github:NixOS/nixpkgs/09061f748ee21f68a089cd5d91ec1859cd93d0be#git",
204+      "source": "devbox-search",
205+      "version": "2.53.0",
206+      "systems": {
207+        "aarch64-darwin": {
208+          "outputs": [
209+            {
210+              "name": "out",
211+              "path": "/nix/store/6qbj40r0s289k5slmy8yna5x2hfz01wg-git-2.53.0",
212+              "default": true
213+            },
214+            {
215+              "name": "doc",
216+              "path": "/nix/store/xp9w7bcl4c78268kn82qdayqglj0zdxa-git-2.53.0-doc"
217+            }
218+          ],
219+          "store_path": "/nix/store/6qbj40r0s289k5slmy8yna5x2hfz01wg-git-2.53.0"
220+        },
221+        "aarch64-linux": {
222+          "outputs": [
223+            {
224+              "name": "out",
225+              "path": "/nix/store/0vbb6j0ppx1w8cw28h7w8s2dzla9j3m6-git-2.53.0",
226+              "default": true
227+            },
228+            {
229+              "name": "debug",
230+              "path": "/nix/store/gaj4q67pid2pcy9nvh2ysv4728wxj5m4-git-2.53.0-debug"
231+            },
232+            {
233+              "name": "doc",
234+              "path": "/nix/store/9mrg9g0w2b4cfdppq3n4zvhkvyixvqpx-git-2.53.0-doc"
235+            }
236+          ],
237+          "store_path": "/nix/store/0vbb6j0ppx1w8cw28h7w8s2dzla9j3m6-git-2.53.0"
238+        },
239+        "x86_64-darwin": {
240+          "outputs": [
241+            {
242+              "name": "out",
243+              "path": "/nix/store/2q2hzaclp1rsj65h21lng7wa26vawhnq-git-2.53.0",
244+              "default": true
245+            },
246+            {
247+              "name": "doc",
248+              "path": "/nix/store/yi2mi426la2x7rggv0a0ah11s2dangz4-git-2.53.0-doc"
249+            }
250+          ],
251+          "store_path": "/nix/store/2q2hzaclp1rsj65h21lng7wa26vawhnq-git-2.53.0"
252+        },
253+        "x86_64-linux": {
254+          "outputs": [
255+            {
256+              "name": "out",
257+              "path": "/nix/store/7yvcckar1lzhqnr0xx2n19nsdjd4qa4d-git-2.53.0",
258+              "default": true
259+            },
260+            {
261+              "name": "debug",
262+              "path": "/nix/store/9xs1n97fsdbgnk8cxbdx3hqlz6fdaynb-git-2.53.0-debug"
263+            },
264+            {
265+              "name": "doc",
266+              "path": "/nix/store/rv7cz5lz1qmbdnh3zrpv0j0wa5ivaacq-git-2.53.0-doc"
267+            }
268+          ],
269+          "store_path": "/nix/store/7yvcckar1lzhqnr0xx2n19nsdjd4qa4d-git-2.53.0"
270+        }
271+      }
272+    },
273+    "github:NixOS/nixpkgs/nixpkgs-unstable": {
274+      "last_modified": "2026-03-16T02:27:38Z",
275+      "resolved": "github:NixOS/nixpkgs/f8573b9c935cfaa162dd62cc9e75ae2db86f85df?lastModified=1773628058&narHash=sha256-hpXH0z3K9xv0fHaje136KY872VT2T5uwxtezlAskQgY%3D"
276+    },
277+    "gnumake@latest": {
278+      "last_modified": "2026-03-29T14:22:01Z",
279+      "resolved": "github:NixOS/nixpkgs/c397ef6af68c018462d786e1b65384abc472a907#gnumake",
280+      "source": "devbox-search",
281+      "version": "4.4.1",
282+      "systems": {
283+        "aarch64-darwin": {
284+          "outputs": [
285+            {
286+              "name": "out",
287+              "path": "/nix/store/y40n7jzzy9qydb120kxgbzi55mprbkfm-gnumake-4.4.1",
288+              "default": true
289+            },
290+            {
291+              "name": "man",
292+              "path": "/nix/store/b2y9y7i57sml44mk7wl4ba8wr8adgavs-gnumake-4.4.1-man",
293+              "default": true
294+            },
295+            {
296+              "name": "doc",
297+              "path": "/nix/store/hb81l6za1swj09szhxj902bjf9b9cwzc-gnumake-4.4.1-doc"
298+            },
299+            {
300+              "name": "info",
301+              "path": "/nix/store/xynyfhcn9r7jd4iakdr71yb6grabjgf7-gnumake-4.4.1-info"
302+            }
303+          ],
304+          "store_path": "/nix/store/y40n7jzzy9qydb120kxgbzi55mprbkfm-gnumake-4.4.1"
305+        },
306+        "aarch64-linux": {
307+          "outputs": [
308+            {
309+              "name": "out",
310+              "path": "/nix/store/j8hf0sbds6y5il4vb2bz3rx0xivmnsl1-gnumake-4.4.1",
311+              "default": true
312+            },
313+            {
314+              "name": "man",
315+              "path": "/nix/store/j4psv3ifmnw0wa8p38rxv5k6vskz4wcs-gnumake-4.4.1-man",
316+              "default": true
317+            },
318+            {
319+              "name": "debug",
320+              "path": "/nix/store/px43zlq4bz3zjmc91b6wb6949c8dfxvi-gnumake-4.4.1-debug"
321+            },
322+            {
323+              "name": "doc",
324+              "path": "/nix/store/jhss2k9jczl72jk284kvaj9303yvylwy-gnumake-4.4.1-doc"
325+            },
326+            {
327+              "name": "info",
328+              "path": "/nix/store/1qf6j3yza10lw1iy9w0qm9gliqzfw6xk-gnumake-4.4.1-info"
329+            }
330+          ],
331+          "store_path": "/nix/store/j8hf0sbds6y5il4vb2bz3rx0xivmnsl1-gnumake-4.4.1"
332+        },
333+        "x86_64-darwin": {
334+          "outputs": [
335+            {
336+              "name": "out",
337+              "path": "/nix/store/bijx0sq7avqq7apvqsfb3lmza97lpcxz-gnumake-4.4.1",
338+              "default": true
339+            },
340+            {
341+              "name": "man",
342+              "path": "/nix/store/r2qnj53aynk05zan3z2j960klx7085dq-gnumake-4.4.1-man",
343+              "default": true
344+            },
345+            {
346+              "name": "doc",
347+              "path": "/nix/store/2fz6r9nchhyn4i7f9gg5assx9ld2j3mm-gnumake-4.4.1-doc"
348+            },
349+            {
350+              "name": "info",
351+              "path": "/nix/store/h0jgx7zbyyvhcj0lq0la6siv4d3bl9xa-gnumake-4.4.1-info"
352+            }
353+          ],
354+          "store_path": "/nix/store/bijx0sq7avqq7apvqsfb3lmza97lpcxz-gnumake-4.4.1"
355+        },
356+        "x86_64-linux": {
357+          "outputs": [
358+            {
359+              "name": "out",
360+              "path": "/nix/store/45npani18v2m7sbkrzrv2xyilyghrny9-gnumake-4.4.1",
361+              "default": true
362+            },
363+            {
364+              "name": "man",
365+              "path": "/nix/store/p75qwzzsxdhpnhdjx4fbjy20p9qxp326-gnumake-4.4.1-man",
366+              "default": true
367+            },
368+            {
369+              "name": "debug",
370+              "path": "/nix/store/bzap48lhfqcjczhj2ry3gwqjh9klkgxf-gnumake-4.4.1-debug"
371+            },
372+            {
373+              "name": "doc",
374+              "path": "/nix/store/0jx03f9vis4zp8b7s2ybp9anlgk68ihy-gnumake-4.4.1-doc"
375+            },
376+            {
377+              "name": "info",
378+              "path": "/nix/store/pbs6fhcn3gjr6x9fzdlk0kwqg1m5gk5g-gnumake-4.4.1-info"
379+            }
380+          ],
381+          "store_path": "/nix/store/45npani18v2m7sbkrzrv2xyilyghrny9-gnumake-4.4.1"
382+        }
383+      }
384+    },
385+    "go@latest": {
386+      "last_modified": "2026-03-21T07:29:51Z",
387+      "resolved": "github:NixOS/nixpkgs/09061f748ee21f68a089cd5d91ec1859cd93d0be#go",
388+      "source": "devbox-search",
389+      "version": "1.26.1",
390+      "systems": {
391+        "aarch64-darwin": {
392+          "outputs": [
393+            {
394+              "name": "out",
395+              "path": "/nix/store/kh43nhaz1qcpwws2xq805lrmwpmn9i3k-go-1.26.1",
396+              "default": true
397+            }
398+          ],
399+          "store_path": "/nix/store/kh43nhaz1qcpwws2xq805lrmwpmn9i3k-go-1.26.1"
400+        },
401+        "aarch64-linux": {
402+          "outputs": [
403+            {
404+              "name": "out",
405+              "path": "/nix/store/rz1pqbm5z3zfby250i0djfmfzzj7khg9-go-1.26.1",
406+              "default": true
407+            }
408+          ],
409+          "store_path": "/nix/store/rz1pqbm5z3zfby250i0djfmfzzj7khg9-go-1.26.1"
410+        },
411+        "x86_64-darwin": {
412+          "outputs": [
413+            {
414+              "name": "out",
415+              "path": "/nix/store/yv6jj27racylbfjw6a1cdr91ndxbgyf6-go-1.26.1",
416+              "default": true
417+            }
418+          ],
419+          "store_path": "/nix/store/yv6jj27racylbfjw6a1cdr91ndxbgyf6-go-1.26.1"
420+        },
421+        "x86_64-linux": {
422+          "outputs": [
423+            {
424+              "name": "out",
425+              "path": "/nix/store/ckcq2mj8zk0drhaaacy6mp9d924hnr4m-go-1.26.1",
426+              "default": true
427+            }
428+          ],
429+          "store_path": "/nix/store/ckcq2mj8zk0drhaaacy6mp9d924hnr4m-go-1.26.1"
430+        }
431+      }
432+    },
433+    "nodejs@latest": {
434+      "last_modified": "2026-03-27T11:17:38Z",
435+      "plugin_version": "0.0.2",
436+      "resolved": "github:NixOS/nixpkgs/832efc09b4caf6b4569fbf9dc01bec3082a00611#nodejs_25",
437+      "source": "devbox-search",
438+      "version": "25.8.2",
439+      "systems": {
440+        "aarch64-darwin": {
441+          "outputs": [
442+            {
443+              "name": "out",
444+              "path": "/nix/store/qflz0c97921bn5i3jc49dy24hhwpk67d-nodejs-25.8.2",
445+              "default": true
446+            }
447+          ],
448+          "store_path": "/nix/store/qflz0c97921bn5i3jc49dy24hhwpk67d-nodejs-25.8.2"
449+        },
450+        "aarch64-linux": {
451+          "outputs": [
452+            {
453+              "name": "out",
454+              "path": "/nix/store/vdakqgvs2qjrdsrjz3wpwn39yvj0fmvv-nodejs-25.8.2",
455+              "default": true
456+            }
457+          ],
458+          "store_path": "/nix/store/vdakqgvs2qjrdsrjz3wpwn39yvj0fmvv-nodejs-25.8.2"
459+        },
460+        "x86_64-darwin": {
461+          "outputs": [
462+            {
463+              "name": "out",
464+              "path": "/nix/store/d94jkiwkdf3qm25pchchdx37sh4fy4g7-nodejs-25.8.2",
465+              "default": true
466+            }
467+          ],
468+          "store_path": "/nix/store/d94jkiwkdf3qm25pchchdx37sh4fy4g7-nodejs-25.8.2"
469+        },
470+        "x86_64-linux": {
471+          "outputs": [
472+            {
473+              "name": "out",
474+              "path": "/nix/store/qads515hyg9b6vs0l1cyxhggvc98ycdx-nodejs-25.8.2",
475+              "default": true
476+            }
477+          ],
478+          "store_path": "/nix/store/qads515hyg9b6vs0l1cyxhggvc98ycdx-nodejs-25.8.2"
479+        }
480+      }
481+    },
482+    "ripgrep@latest": {
483+      "last_modified": "2026-03-21T07:29:51Z",
484+      "resolved": "github:NixOS/nixpkgs/09061f748ee21f68a089cd5d91ec1859cd93d0be#ripgrep",
485+      "source": "devbox-search",
486+      "version": "15.1.0",
487+      "systems": {
488+        "aarch64-darwin": {
489+          "outputs": [
490+            {
491+              "name": "out",
492+              "path": "/nix/store/qx2i265ck9hi773q0hhg96bjchkghpgm-ripgrep-15.1.0",
493+              "default": true
494+            }
495+          ],
496+          "store_path": "/nix/store/qx2i265ck9hi773q0hhg96bjchkghpgm-ripgrep-15.1.0"
497+        },
498+        "aarch64-linux": {
499+          "outputs": [
500+            {
501+              "name": "out",
502+              "path": "/nix/store/p16w1972rd9hj2kdczmdlwv0wa8c0drf-ripgrep-15.1.0",
503+              "default": true
504+            }
505+          ],
506+          "store_path": "/nix/store/p16w1972rd9hj2kdczmdlwv0wa8c0drf-ripgrep-15.1.0"
507+        },
508+        "x86_64-darwin": {
509+          "outputs": [
510+            {
511+              "name": "out",
512+              "path": "/nix/store/hm6mgwzrdznpadk6nxzmlls1p534m2d3-ripgrep-15.1.0",
513+              "default": true
514+            }
515+          ],
516+          "store_path": "/nix/store/hm6mgwzrdznpadk6nxzmlls1p534m2d3-ripgrep-15.1.0"
517+        },
518+        "x86_64-linux": {
519+          "outputs": [
520+            {
521+              "name": "out",
522+              "path": "/nix/store/922crn2k3v8yaqk7anps80hba919lnds-ripgrep-15.1.0",
523+              "default": true
524+            }
525+          ],
526+          "store_path": "/nix/store/922crn2k3v8yaqk7anps80hba919lnds-ripgrep-15.1.0"
527+        }
528+      }
529+    },
530+    "yarn@latest": {
531+      "last_modified": "2026-03-21T07:29:51Z",
532+      "resolved": "github:NixOS/nixpkgs/09061f748ee21f68a089cd5d91ec1859cd93d0be#yarn",
533+      "source": "devbox-search",
534+      "version": "1.22.22",
535+      "systems": {
536+        "aarch64-darwin": {
537+          "outputs": [
538+            {
539+              "name": "out",
540+              "path": "/nix/store/59yqhmvbl7bsyv5qc30nmrpaf4iqj79h-yarn-1.22.22",
541+              "default": true
542+            }
543+          ],
544+          "store_path": "/nix/store/59yqhmvbl7bsyv5qc30nmrpaf4iqj79h-yarn-1.22.22"
545+        },
546+        "aarch64-linux": {
547+          "outputs": [
548+            {
549+              "name": "out",
550+              "path": "/nix/store/ybspc2b9q9j9drfpa5d1bmw2qgrkx4qc-yarn-1.22.22",
551+              "default": true
552+            }
553+          ],
554+          "store_path": "/nix/store/ybspc2b9q9j9drfpa5d1bmw2qgrkx4qc-yarn-1.22.22"
555+        },
556+        "x86_64-darwin": {
557+          "outputs": [
558+            {
559+              "name": "out",
560+              "path": "/nix/store/8j2xmk7ijy7nijpy2yc3mcjf7hxpnpyl-yarn-1.22.22",
561+              "default": true
562+            }
563+          ],
564+          "store_path": "/nix/store/8j2xmk7ijy7nijpy2yc3mcjf7hxpnpyl-yarn-1.22.22"
565+        },
566+        "x86_64-linux": {
567+          "outputs": [
568+            {
569+              "name": "out",
570+              "path": "/nix/store/g1ksanljq7v16gj8yb7zs6wkv7ikycy3-yarn-1.22.22",
571+              "default": true
572+            }
573+          ],
574+          "store_path": "/nix/store/g1ksanljq7v16gj8yb7zs6wkv7ikycy3-yarn-1.22.22"
575+        }
576+      }
577+    }
578+  }
579+}
A go.mod
+17, -0
 1@@ -0,0 +1,17 @@
 2+module github.com/picosh/pgit
 3+
 4+go 1.24
 5+
 6+require (
 7+	github.com/alecthomas/chroma/v2 v2.13.0
 8+	github.com/dustin/go-humanize v1.0.0
 9+	github.com/gogs/git-module v1.6.0
10+	github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab
11+)
12+
13+require (
14+	github.com/dlclark/regexp2 v1.11.0 // indirect
15+	github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75 // indirect
16+	github.com/stretchr/testify v1.8.1 // indirect
17+	golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect
18+)
A go.sum
+39, -0
 1@@ -0,0 +1,39 @@
 2+github.com/alecthomas/assert/v2 v2.6.0 h1:o3WJwILtexrEUk3cUVal3oiQY2tfgr/FHWiz/v2n4FU=
 3+github.com/alecthomas/assert/v2 v2.6.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
 4+github.com/alecthomas/chroma/v2 v2.13.0 h1:VP72+99Fb2zEcYM0MeaWJmV+xQvz5v5cxRHd+ooU1lI=
 5+github.com/alecthomas/chroma/v2 v2.13.0/go.mod h1:BUGjjsD+ndS6eX37YgTchSEG+Jg9Jv1GiZs9sqPqztk=
 6+github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
 7+github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
 8+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 9+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
10+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
11+github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
12+github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
13+github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
14+github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
15+github.com/gogs/git-module v1.6.0 h1:71GdRM9/pFxGgSUz8t2DKmm3RYuHUnTjsOuFInJXnkM=
16+github.com/gogs/git-module v1.6.0/go.mod h1:8jFYhDxLUwEOhM2709l2CJXmoIIslobU1xszpT0NcAI=
17+github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=
18+github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
19+github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
20+github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
21+github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75 h1:Pijfgr7ZuvX7QIQiEwLdRVr3RoMG+i0SbBO1Qu+7yVk=
22+github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75/go.mod h1:76rfSfYPWj01Z85hUf/ituArm797mNKcvINh1OlsZKo=
23+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
24+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
25+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
26+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
27+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
28+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
29+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
30+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
31+github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
32+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
33+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
34+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 h1:uVc8UZUe6tr40fFVnUP5Oj+veunVezqYl9z7DYw9xzw=
35+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
36+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
37+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
38+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
39+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
40+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
A html/base.layout.tmpl
+25, -0
 1@@ -0,0 +1,25 @@
 2+{{define "base"}}
 3+<!doctype html>
 4+<html lang="en">
 5+  <head>
 6+    <meta charset='utf-8'>
 7+    <meta name="viewport" content="width=device-width, initial-scale=1" />
 8+    <title>{{template "title" .}}</title>
 9+
10+    <meta name="keywords" content="git code forge repo repository" />
11+
12+    {{template "meta" .}}
13+
14+    <link rel="stylesheet" href="{{.Repo.RootRelative}}vars.css" />
15+    <link rel="stylesheet" href="{{.Repo.RootRelative}}smol.css" />
16+    <link rel="stylesheet" href="{{.Repo.RootRelative}}main.css" />
17+  </head>
18+  <body>
19+    <header class="box">{{template "header" .}}</header>
20+    <hr class="my" />
21+    <main>{{template "content" .}}</main>
22+    <hr class="my" />
23+    <footer>{{template "footer" .}}</footer>
24+  </body>
25+</html>
26+{{end}}
A html/commit.page.tmpl
+56, -0
 1@@ -0,0 +1,56 @@
 2+{{template "base" .}}
 3+{{define "title"}}{{.Commit.Summary}} - {{.Repo.RepoName}}@{{.CommitID}}{{end}}
 4+{{define "meta"}}
 5+<link rel="stylesheet" href="{{.Repo.RootRelative}}syntax.css" />
 6+{{end}}
 7+
 8+{{define "content"}}
 9+  <dl>
10+    <dt>commit</dt>
11+    <dd><a href="{{.CommitURL}}">{{.CommitID}}</a></dd>
12+
13+    <dt>parent</dt>
14+    <dd><a href="{{.ParentURL}}">{{.Parent}}</a></dd>
15+
16+    <dt>author</dt>
17+    <dd>{{.Commit.Author.Name}}</dd>
18+
19+    <dt>date</dt>
20+    <dd>{{.Commit.Author.When}}</dd>
21+  </dl>
22+
23+  <pre class="white-space-bs">{{.Commit.Message}}</pre>
24+
25+  <div class="box mono">
26+    <div>
27+      <strong>{{.Diff.NumFiles}}</strong> files changed,&nbsp;
28+      <span class="color-green">+{{.Diff.TotalAdditions}}</span>,
29+      <span class="color-red">-{{.Diff.TotalDeletions}}</span>
30+    </div>
31+
32+    <div>
33+    {{range .Diff.Files}}
34+      <div class="my-sm">
35+        <span>{{.FileType}}</span>
36+        <a href="#diff-{{.Name}}">{{.Name}}</a>
37+      </div>
38+    {{end}}
39+    </div>
40+  </div>
41+
42+  {{range .Diff.Files}}
43+    <div id="diff-{{.Name}}" class="flex justify-between mono py diff-file">
44+      <div>
45+        <span>{{.FileType}} {{if eq .FileType "R"}}{{.OldName}} => {{end}}</span>
46+        <a href="#diff-{{.Name}}">{{.Name}}</a>
47+      </div>
48+
49+      <div>
50+        <span class="color-green">+{{.NumAdditions}}</span>,
51+        <span class="color-red">-{{.NumDeletions}}</span>
52+      </div>
53+    </div>
54+
55+    {{.Content}}
56+  {{end}}
57+{{end}}
A html/file.page.tmpl
+37, -0
 1@@ -0,0 +1,37 @@
 2+{{template "base" .}}
 3+{{define "title"}}{{.Item.Path}}@{{.RevData.Name}}{{end}}
 4+{{define "meta"}}
 5+<link rel="stylesheet" href="{{.Repo.RootRelative}}syntax.css" />
 6+{{end}}
 7+
 8+{{define "content"}}
 9+  <div class="text-md text-transform-none">
10+    {{range .Item.Crumbs}}
11+      <a href="{{.URL}}">{{.Text}}</a> {{if .IsLast}}{{else}}/{{end}}
12+    {{end}}
13+  </div>
14+
15+  {{if .Repo.HideTreeLastCommit}}
16+  {{else}}
17+  <div class="box">
18+    <div class="flex items-center justify-between">
19+      <div class="flex-1">
20+        <a href="{{.Item.CommitURL}}">{{.Item.Summary}}</a>
21+      </div>
22+      <div class="mono">
23+        <a href="{{.Item.CommitURL}}">{{.Item.CommitID}}</a>
24+      </div>
25+    </div>
26+
27+    <div class="flex items-center">
28+      <span>{{.Item.Author.Name}}</span>
29+      <span>&nbsp;&centerdot;&nbsp;</span>
30+      <span>{{.Item.When}}</span>
31+    </div>
32+  </div>
33+  {{end}}
34+
35+  <h2 class="text-lg text-transform-none">{{.Item.Name}}</h2>
36+
37+  {{.Contents}}
38+{{end }}
A html/footer.partial.tmpl
+5, -0
1@@ -0,0 +1,5 @@
2+{{define "footer"}}
3+<div>
4+  built with <a href="https://pgit.pico.sh">pgit</a>
5+</div>
6+{{end}}
A html/header.partial.tmpl
+24, -0
 1@@ -0,0 +1,24 @@
 2+{{define "header"}}
 3+<div class="flex flex-col">
 4+  <h1 class="text-xl flex gap p-0">
 5+    {{if .SiteURLs.HomeURL}}
 6+      <a href="{{.SiteURLs.HomeURL}}">repos</a>
 7+      <span>/</span>
 8+    {{end}}
 9+    <span>{{.Repo.RepoName}}</span>
10+  </h1>
11+
12+  <nav>
13+    <a href="{{.SiteURLs.SummaryURL}}">summary</a> |
14+    <a href="{{.SiteURLs.RefsURL}}">refs</a> |
15+    <span class="font-bold">{{.RevData.Name}}</span> |
16+    <a href="{{.RevData.TreeURL}}">code</a> |
17+    <a href="{{.RevData.LogURL}}">commits</a>
18+  </nav>
19+
20+  <div>
21+    <div>{{.Repo.Desc}}</div>
22+    {{if .SiteURLs.CloneURL}}<pre class="mb-0">git clone {{.SiteURLs.CloneURL}}</pre>{{end}}
23+  </div>
24+</div>
25+{{end}}
A html/log.page.tmpl
+37, -0
 1@@ -0,0 +1,37 @@
 2+{{template "base" .}}
 3+
 4+{{define "title"}}commits - {{.Repo.RepoName}}@{{.RevData.Name}}{{end}}
 5+{{define "meta"}}{{end}}
 6+
 7+{{define "content"}}
 8+  <div class="group-2">
 9+    <div><span class="font-bold">({{.NumCommits}})</span> commits</div>
10+    {{range .Logs}}
11+      <div>
12+        <div class="flex justify-between items-center">
13+          <a href="{{.URL}}" class="mono">{{.ShortID}}</a>
14+
15+          <div class="mono">
16+            {{range .Refs}}
17+              {{if .URL}}
18+                <a href="{{.URL}}">({{.Refspec}})</a>
19+              {{else}}
20+                ({{.Refspec}})
21+              {{end}}
22+            {{end}}
23+          </div>
24+        </div>
25+
26+        <div class="flex items-center gap-xs text-sm">
27+          <span>{{.AuthorStr}}</span>
28+          <span>&nbsp;&centerdot;&nbsp;</span>
29+          <span>{{.WhenStr}}</span>
30+        </div>
31+
32+        <div>
33+          <pre class="m-0 white-space-bs">{{.Message}}</pre>
34+        </div>
35+      </div>
36+    {{end}}
37+  </div>
38+{{end}}
A html/refs.page.tmpl
+18, -0
 1@@ -0,0 +1,18 @@
 2+{{template "base" .}}
 3+
 4+{{define "title"}}refs - {{.Repo.RepoName}}{{end}}
 5+{{define "meta"}}{{end}}
 6+
 7+{{define "content"}}
 8+  <h2 class="text-lg font-bold">refs</h2>
 9+
10+  <ul>
11+  {{range .Refs}}
12+    {{if .URL}}
13+      <li><a href="{{.URL}}">{{.Refspec}}</a></li>
14+    {{else}}
15+      <li>{{.Refspec}}</li>
16+    {{end}}
17+  {{end}}
18+  </ul>
19+{{end}}
A html/summary.page.tmpl
+10, -0
 1@@ -0,0 +1,10 @@
 2+{{template "base" .}}
 3+
 4+{{define "title"}}{{.Repo.RepoName}}{{if .Repo.Desc}}- {{.Repo.Desc}}{{end}}{{end}}
 5+{{define "meta"}}
 6+<link rel="stylesheet" href="{{.Repo.RootRelative}}syntax.css" />
 7+{{end}}
 8+
 9+{{define "content"}}
10+  {{.Readme}}
11+{{end}}
A html/tree.page.tmpl
+51, -0
 1@@ -0,0 +1,51 @@
 2+{{template "base" .}}
 3+
 4+{{define "title"}}files - {{.Repo.RepoName}}@{{.RevData.Name}}{{end}}
 5+{{define "meta"}}{{end}}
 6+
 7+{{define "content"}}
 8+  <div>
 9+    <div class="text-md text-transform-none mb">
10+      {{range .Tree.Crumbs}}
11+        {{if .IsLast}}
12+          <span class="font-bold">{{.Text}}</span>
13+        {{else}}
14+          <a href="{{.URL}}">{{.Text}}</a> {{if .IsLast}}{{else}}/{{end}}
15+        {{end}}
16+      {{end}}
17+    </div>
18+
19+    {{range .Tree.Items}}
20+      <div class="flex justify-between items-center gap-2 p tree-row border-b">
21+        <div class="flex-1 tree-path flex items-center gap">
22+          {{if .IsDir}}
23+            <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" height="16" width="16" viewBox="0 0 512 512">
24+              <path d="M0 96C0 60.7 28.7 32 64 32H196.1c19.1 0 37.4 7.6 50.9 21.1L289.9 96H448c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V96zM64 80c-8.8 0-16 7.2-16 16V416c0 8.8 7.2 16 16 16H448c8.8 0 16-7.2 16-16V160c0-8.8-7.2-16-16-16H286.6c-10.6 0-20.8-4.2-28.3-11.7L213.1 87c-4.5-4.5-10.6-7-17-7H64z"/>
25+            </svg>
26+          {{else}}
27+            <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" height="16" width="16" viewBox="0 0 384 512">
28+              <path d="M320 464c8.8 0 16-7.2 16-16V160H256c-17.7 0-32-14.3-32-32V48H64c-8.8 0-16 7.2-16 16V448c0 8.8 7.2 16 16 16H320zM0 64C0 28.7 28.7 0 64 0H229.5c17 0 33.3 6.7 45.3 18.7l90.5 90.5c12 12 18.7 28.3 18.7 45.3V448c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V64z"/>
29+            </svg>
30+          {{end}}
31+
32+          <a href="{{.URL}}">{{.Name}}</a>
33+        </div>
34+
35+        <div class="flex items-center gap">
36+          {{if $.Repo.HideTreeLastCommit}}
37+          {{else}}
38+          <div class="flex-1 tree-commit">
39+            <a href="{{.CommitURL}}" title="{{.Summary}}">{{.When}}</a>
40+          </div>
41+          {{end}}
42+          <div class="tree-size">
43+            {{if .IsDir}}
44+            {{else}}
45+              {{if .IsTextFile}}{{.NumLines}} L{{else}}{{.Size}}{{end}}
46+            {{end}}
47+          </div>
48+        </div>
49+      </div>
50+    {{end}}
51+  </div>
52+{{end}}
A main.go
+1169, -0
   1@@ -0,0 +1,1169 @@
   2+package main
   3+
   4+import (
   5+	"bytes"
   6+	"embed"
   7+	"flag"
   8+	"fmt"
   9+	"html/template"
  10+	"log/slog"
  11+	"math"
  12+	"os"
  13+	"path/filepath"
  14+	"sort"
  15+	"strings"
  16+	"sync"
  17+	"time"
  18+	"unicode/utf8"
  19+
  20+	"github.com/alecthomas/chroma/v2"
  21+	formatterHtml "github.com/alecthomas/chroma/v2/formatters/html"
  22+	"github.com/alecthomas/chroma/v2/lexers"
  23+	"github.com/alecthomas/chroma/v2/styles"
  24+	"github.com/dustin/go-humanize"
  25+	git "github.com/gogs/git-module"
  26+	"github.com/gomarkdown/markdown"
  27+)
  28+
  29+//go:embed html/*.tmpl
  30+var embedFS embed.FS
  31+
  32+//go:embed static/*
  33+var staticFS embed.FS
  34+
  35+type Config struct {
  36+	// required params
  37+	Outdir string
  38+	// abs path to git repo
  39+	RepoPath string
  40+
  41+	// optional params
  42+	// generate logs anad tree based on the git revisions provided
  43+	Revs []string
  44+	// description of repo used in the header of site
  45+	Desc string
  46+	// maximum number of commits that we will process in descending order
  47+	MaxCommits int
  48+	// name of the readme file
  49+	Readme string
  50+	// In order to get the latest commit per file we do a `git rev-list {ref} {file}`
  51+	// which is n+1 where n is a file in the tree.
  52+	// We offer a way to disable showing the latest commit in the output
  53+	// for those who want a faster build time
  54+	HideTreeLastCommit bool
  55+
  56+	// user-defined urls
  57+	HomeURL  template.URL
  58+	CloneURL template.URL
  59+
  60+	// https://developer.mozilla.org/en-US/docs/Web/API/URL_API/Resolving_relative_references#root_relative
  61+	RootRelative string
  62+
  63+	// computed
  64+	// cache for skipping commits, trees, etc.
  65+	Cache map[string]bool
  66+	// mutex for Cache
  67+	Mutex sync.RWMutex
  68+	// pretty name for the repo
  69+	RepoName string
  70+	// logger
  71+	Logger *slog.Logger
  72+	// chroma style
  73+	Theme     *chroma.Style
  74+	Formatter *formatterHtml.Formatter
  75+}
  76+
  77+type RevInfo interface {
  78+	ID() string
  79+	Name() string
  80+}
  81+
  82+type RevData struct {
  83+	id     string
  84+	name   string
  85+	Config *Config
  86+}
  87+
  88+func (r *RevData) ID() string {
  89+	return r.id
  90+}
  91+
  92+func (r *RevData) Name() string {
  93+	return r.name
  94+}
  95+
  96+func (r *RevData) TreeURL() template.URL {
  97+	return r.Config.getTreeURL(r)
  98+}
  99+
 100+func (r *RevData) LogURL() template.URL {
 101+	return r.Config.getLogsURL(r)
 102+}
 103+
 104+type TagData struct {
 105+	Name string
 106+	URL  template.URL
 107+}
 108+
 109+type CommitData struct {
 110+	SummaryStr string
 111+	URL        template.URL
 112+	WhenStr    string
 113+	AuthorStr  string
 114+	ShortID    string
 115+	ParentID   string
 116+	Refs       []*RefInfo
 117+	*git.Commit
 118+}
 119+
 120+type TreeItem struct {
 121+	IsTextFile bool
 122+	IsDir      bool
 123+	Size       string
 124+	NumLines   int
 125+	Name       string
 126+	Icon       string
 127+	Path       string
 128+	URL        template.URL
 129+	CommitID   string
 130+	CommitURL  template.URL
 131+	Summary    string
 132+	When       string
 133+	Author     *git.Signature
 134+	Entry      *git.TreeEntry
 135+	Crumbs     []*Breadcrumb
 136+}
 137+
 138+type DiffRender struct {
 139+	NumFiles       int
 140+	TotalAdditions int
 141+	TotalDeletions int
 142+	Files          []*DiffRenderFile
 143+}
 144+
 145+type DiffRenderFile struct {
 146+	FileType     string
 147+	OldMode      git.EntryMode
 148+	OldName      string
 149+	Mode         git.EntryMode
 150+	Name         string
 151+	Content      template.HTML
 152+	NumAdditions int
 153+	NumDeletions int
 154+}
 155+
 156+type RefInfo struct {
 157+	ID      string
 158+	Refspec string
 159+	URL     template.URL
 160+}
 161+
 162+type BranchOutput struct {
 163+	Readme     string
 164+	LastCommit *git.Commit
 165+}
 166+
 167+type SiteURLs struct {
 168+	HomeURL    template.URL
 169+	CloneURL   template.URL
 170+	SummaryURL template.URL
 171+	RefsURL    template.URL
 172+}
 173+
 174+type PageData struct {
 175+	Repo     *Config
 176+	SiteURLs *SiteURLs
 177+	RevData  *RevData
 178+}
 179+
 180+type SummaryPageData struct {
 181+	*PageData
 182+	Readme template.HTML
 183+}
 184+
 185+type TreePageData struct {
 186+	*PageData
 187+	Tree *TreeRoot
 188+}
 189+
 190+type LogPageData struct {
 191+	*PageData
 192+	NumCommits int
 193+	Logs       []*CommitData
 194+}
 195+
 196+type FilePageData struct {
 197+	*PageData
 198+	Contents template.HTML
 199+	Item     *TreeItem
 200+}
 201+
 202+type CommitPageData struct {
 203+	*PageData
 204+	CommitMsg template.HTML
 205+	CommitID  string
 206+	Commit    *CommitData
 207+	Diff      *DiffRender
 208+	Parent    string
 209+	ParentURL template.URL
 210+	CommitURL template.URL
 211+}
 212+
 213+type RefPageData struct {
 214+	*PageData
 215+	Refs []*RefInfo
 216+}
 217+
 218+type WriteData struct {
 219+	Template string
 220+	Filename string
 221+	Subdir   string
 222+	Data     any
 223+}
 224+
 225+func bail(err error) {
 226+	if err != nil {
 227+		panic(err)
 228+	}
 229+}
 230+
 231+func diffFileType(_type git.DiffFileType) string {
 232+	switch _type {
 233+	case git.DiffFileAdd:
 234+		return "A"
 235+	case git.DiffFileChange:
 236+		return "M"
 237+	case git.DiffFileDelete:
 238+		return "D"
 239+	case git.DiffFileRename:
 240+		return "R"
 241+	default:
 242+		return ""
 243+	}
 244+}
 245+
 246+// converts contents of files in git tree to pretty formatted code.
 247+func (c *Config) parseText(filename string, text string) (string, error) {
 248+	lexer := lexers.Match(filename)
 249+	if lexer == nil {
 250+		lexer = lexers.Analyse(text)
 251+	}
 252+	if lexer == nil {
 253+		lexer = lexers.Get("plaintext")
 254+	}
 255+	iterator, err := lexer.Tokenise(nil, text)
 256+	if err != nil {
 257+		return text, err
 258+	}
 259+	var buf bytes.Buffer
 260+	err = c.Formatter.Format(&buf, c.Theme, iterator)
 261+	if err != nil {
 262+		return text, err
 263+	}
 264+	return buf.String(), nil
 265+}
 266+
 267+// isText reports whether a significant prefix of s looks like correct UTF-8;
 268+// that is, if it is likely that s is human-readable text.
 269+func isText(s string) bool {
 270+	const max = 1024 // at least utf8.UTFMax
 271+	if len(s) > max {
 272+		s = s[0:max]
 273+	}
 274+	for i, c := range s {
 275+		if i+utf8.UTFMax > len(s) {
 276+			// last char may be incomplete - ignore
 277+			break
 278+		}
 279+		if c == 0xFFFD || c < ' ' && c != '\n' && c != '\t' && c != '\f' && c != '\r' {
 280+			// decoding error or control character - not a text file
 281+			return false
 282+		}
 283+	}
 284+	return true
 285+}
 286+
 287+// isTextFile reports whether the file has a known extension indicating
 288+// a text file, or if a significant chunk of the specified file looks like
 289+// correct UTF-8; that is, if it is likely that the file contains human-
 290+// readable text.
 291+func isTextFile(text string) bool {
 292+	num := math.Min(float64(len(text)), 1024)
 293+	return isText(text[0:int(num)])
 294+}
 295+
 296+// isMarkdownFile reports whether the file has a .md extension (case-insensitive).
 297+func isMarkdownFile(filename string) bool {
 298+	ext := strings.ToLower(filepath.Ext(filename))
 299+	return ext == ".md"
 300+}
 301+
 302+// renderMarkdown converts markdown text to HTML.
 303+func renderMarkdown(mdText string) template.HTML {
 304+	md := []byte(mdText)
 305+	htmlBytes := markdown.ToHTML(md, nil, nil)
 306+	return template.HTML(htmlBytes)
 307+}
 308+
 309+func toPretty(b int64) string {
 310+	return humanize.Bytes(uint64(b))
 311+}
 312+
 313+func repoName(root string) string {
 314+	_, file := filepath.Split(root)
 315+	return file
 316+}
 317+
 318+func readmeFile(repo *Config) string {
 319+	if repo.Readme == "" {
 320+		return "readme.md"
 321+	}
 322+
 323+	return strings.ToLower(repo.Readme)
 324+}
 325+
 326+func (c *Config) writeHtml(writeData *WriteData) {
 327+	ts, err := template.ParseFS(
 328+		embedFS,
 329+		writeData.Template,
 330+		"html/header.partial.tmpl",
 331+		"html/footer.partial.tmpl",
 332+		"html/base.layout.tmpl",
 333+	)
 334+	bail(err)
 335+
 336+	dir := filepath.Join(c.Outdir, writeData.Subdir)
 337+	err = os.MkdirAll(dir, os.ModePerm)
 338+	bail(err)
 339+
 340+	fp := filepath.Join(dir, writeData.Filename)
 341+	c.Logger.Info("writing", "filepath", fp)
 342+
 343+	w, err := os.OpenFile(fp, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
 344+	bail(err)
 345+
 346+	err = ts.Execute(w, writeData.Data)
 347+	bail(err)
 348+}
 349+
 350+func (c *Config) copyStatic(dir string) error {
 351+	entries, err := staticFS.ReadDir(dir)
 352+	bail(err)
 353+
 354+	for _, e := range entries {
 355+		infp := filepath.Join(dir, e.Name())
 356+		if e.IsDir() {
 357+			continue
 358+		}
 359+
 360+		w, err := staticFS.ReadFile(infp)
 361+		bail(err)
 362+		fp := filepath.Join(c.Outdir, e.Name())
 363+		c.Logger.Info("writing", "filepath", fp)
 364+		err = os.WriteFile(fp, w, 0644)
 365+		bail(err)
 366+	}
 367+
 368+	return nil
 369+}
 370+
 371+func (c *Config) writeRootSummary(data *PageData, readme template.HTML) {
 372+	c.Logger.Info("writing root html", "repoPath", c.RepoPath)
 373+	c.writeHtml(&WriteData{
 374+		Filename: "index.html",
 375+		Template: "html/summary.page.tmpl",
 376+		Data: &SummaryPageData{
 377+			PageData: data,
 378+			Readme:   readme,
 379+		},
 380+	})
 381+}
 382+
 383+func (c *Config) writeTree(data *PageData, tree *TreeRoot) {
 384+	c.Logger.Info("writing tree", "treePath", tree.Path)
 385+	c.writeHtml(&WriteData{
 386+		Filename: "index.html",
 387+		Subdir:   tree.Path,
 388+		Template: "html/tree.page.tmpl",
 389+		Data: &TreePageData{
 390+			PageData: data,
 391+			Tree:     tree,
 392+		},
 393+	})
 394+}
 395+
 396+func (c *Config) writeLog(data *PageData, logs []*CommitData) {
 397+	c.Logger.Info("writing log file", "revision", data.RevData.Name())
 398+	c.writeHtml(&WriteData{
 399+		Filename: "index.html",
 400+		Subdir:   getLogBaseDir(data.RevData),
 401+		Template: "html/log.page.tmpl",
 402+		Data: &LogPageData{
 403+			PageData:   data,
 404+			NumCommits: len(logs),
 405+			Logs:       logs,
 406+		},
 407+	})
 408+}
 409+
 410+func (c *Config) writeRefs(data *PageData, refs []*RefInfo) {
 411+	c.Logger.Info("writing refs", "repoPath", c.RepoPath)
 412+	c.writeHtml(&WriteData{
 413+		Filename: "refs.html",
 414+		Template: "html/refs.page.tmpl",
 415+		Data: &RefPageData{
 416+			PageData: data,
 417+			Refs:     refs,
 418+		},
 419+	})
 420+}
 421+
 422+func (c *Config) writeHTMLTreeFile(pageData *PageData, treeItem *TreeItem) string {
 423+	readme := ""
 424+	b, err := treeItem.Entry.Blob().Bytes()
 425+	bail(err)
 426+	str := string(b)
 427+
 428+	treeItem.IsTextFile = isTextFile(str)
 429+
 430+	contents := "binary file, cannot display"
 431+	if treeItem.IsTextFile {
 432+		treeItem.NumLines = len(strings.Split(str, "\n"))
 433+		// Use markdown rendering for .md files, syntax highlighting for others
 434+		if isMarkdownFile(treeItem.Entry.Name()) {
 435+			contents = string(renderMarkdown(str))
 436+		} else {
 437+			contents, err = c.parseText(treeItem.Entry.Name(), string(b))
 438+			bail(err)
 439+		}
 440+	}
 441+
 442+	d := filepath.Dir(treeItem.Path)
 443+
 444+	nameLower := strings.ToLower(treeItem.Entry.Name())
 445+	summary := readmeFile(pageData.Repo)
 446+	if d == "." && nameLower == summary {
 447+		// Capture raw markdown content for summary page
 448+		readme = str
 449+	}
 450+
 451+	c.writeHtml(&WriteData{
 452+		Filename: fmt.Sprintf("%s.html", treeItem.Entry.Name()),
 453+		Template: "html/file.page.tmpl",
 454+		Data: &FilePageData{
 455+			PageData: pageData,
 456+			Contents: template.HTML(contents),
 457+			Item:     treeItem,
 458+		},
 459+		Subdir: getFileDir(pageData.RevData, d),
 460+	})
 461+	return readme
 462+}
 463+
 464+func (c *Config) writeLogDiff(repo *git.Repository, pageData *PageData, commit *CommitData) {
 465+	commitID := commit.ID.String()
 466+
 467+	c.Mutex.RLock()
 468+	hasCommit := c.Cache[commitID]
 469+	c.Mutex.RUnlock()
 470+
 471+	if hasCommit {
 472+		c.Logger.Info("commit file already generated, skipping", "commitID", getShortID(commitID))
 473+		return
 474+	} else {
 475+		c.Mutex.Lock()
 476+		c.Cache[commitID] = true
 477+		c.Mutex.Unlock()
 478+	}
 479+
 480+	diff, err := repo.Diff(commitID, 0, 0, 0, git.DiffOptions{})
 481+	bail(err)
 482+
 483+	rnd := &DiffRender{
 484+		NumFiles:       diff.NumFiles(),
 485+		TotalAdditions: diff.TotalAdditions(),
 486+		TotalDeletions: diff.TotalDeletions(),
 487+	}
 488+	fls := []*DiffRenderFile{}
 489+	for _, file := range diff.Files {
 490+		fl := &DiffRenderFile{
 491+			FileType:     diffFileType(file.Type),
 492+			OldMode:      file.OldMode(),
 493+			OldName:      file.OldName(),
 494+			Mode:         file.Mode(),
 495+			Name:         file.Name,
 496+			NumAdditions: file.NumAdditions(),
 497+			NumDeletions: file.NumDeletions(),
 498+		}
 499+		content := ""
 500+		for _, section := range file.Sections {
 501+			for _, line := range section.Lines {
 502+				content += fmt.Sprintf("%s\n", line.Content)
 503+			}
 504+		}
 505+		// set filename to something our `ParseText` recognizes (e.g. `.diff`)
 506+		finContent, err := c.parseText("commit.diff", content)
 507+		bail(err)
 508+
 509+		fl.Content = template.HTML(finContent)
 510+		fls = append(fls, fl)
 511+	}
 512+	rnd.Files = fls
 513+
 514+	commitData := &CommitPageData{
 515+		PageData:  pageData,
 516+		Commit:    commit,
 517+		CommitID:  getShortID(commitID),
 518+		Diff:      rnd,
 519+		Parent:    getShortID(commit.ParentID),
 520+		CommitURL: c.getCommitURL(commitID),
 521+		ParentURL: c.getCommitURL(commit.ParentID),
 522+	}
 523+
 524+	c.writeHtml(&WriteData{
 525+		Filename: fmt.Sprintf("%s.html", commitID),
 526+		Template: "html/commit.page.tmpl",
 527+		Subdir:   "commits",
 528+		Data:     commitData,
 529+	})
 530+}
 531+
 532+func (c *Config) getSummaryURL() template.URL {
 533+	url := c.RootRelative + "index.html"
 534+	return template.URL(url)
 535+}
 536+
 537+func (c *Config) getRefsURL() template.URL {
 538+	url := c.RootRelative + "refs.html"
 539+	return template.URL(url)
 540+}
 541+
 542+// controls the url for trees and logs
 543+// - /logs/getRevIDForURL()/index.html
 544+// - /tree/getRevIDForURL()/item/file.x.html.
 545+func getRevIDForURL(info RevInfo) string {
 546+	return info.Name()
 547+}
 548+
 549+func getTreeBaseDir(info RevInfo) string {
 550+	subdir := getRevIDForURL(info)
 551+	return filepath.Join("/", "tree", subdir)
 552+}
 553+
 554+func getLogBaseDir(info RevInfo) string {
 555+	subdir := getRevIDForURL(info)
 556+	return filepath.Join("/", "logs", subdir)
 557+}
 558+
 559+func getFileBaseDir(info RevInfo) string {
 560+	return filepath.Join(getTreeBaseDir(info), "item")
 561+}
 562+
 563+func getFileDir(info RevInfo, fname string) string {
 564+	return filepath.Join(getFileBaseDir(info), fname)
 565+}
 566+
 567+func (c *Config) getFileURL(info RevInfo, fname string) template.URL {
 568+	return c.compileURL(getFileBaseDir(info), fname)
 569+}
 570+
 571+func (c *Config) compileURL(dir, fname string) template.URL {
 572+	purl := c.RootRelative + strings.TrimPrefix(dir, "/")
 573+	url := filepath.Join(purl, fname)
 574+	return template.URL(url)
 575+}
 576+
 577+func (c *Config) getTreeURL(info RevInfo) template.URL {
 578+	dir := getTreeBaseDir(info)
 579+	return c.compileURL(dir, "index.html")
 580+}
 581+
 582+func (c *Config) getLogsURL(info RevInfo) template.URL {
 583+	dir := getLogBaseDir(info)
 584+	return c.compileURL(dir, "index.html")
 585+}
 586+
 587+func (c *Config) getCommitURL(commitID string) template.URL {
 588+	url := fmt.Sprintf("%scommits/%s.html", c.RootRelative, commitID)
 589+	return template.URL(url)
 590+}
 591+
 592+func (c *Config) getURLs() *SiteURLs {
 593+	return &SiteURLs{
 594+		HomeURL:    c.HomeURL,
 595+		CloneURL:   c.CloneURL,
 596+		RefsURL:    c.getRefsURL(),
 597+		SummaryURL: c.getSummaryURL(),
 598+	}
 599+}
 600+
 601+func getShortID(id string) string {
 602+	return id[:7]
 603+}
 604+
 605+func (c *Config) writeRepo() *BranchOutput {
 606+	c.Logger.Info("writing repo", "repoPath", c.RepoPath)
 607+	repo, err := git.Open(c.RepoPath)
 608+	bail(err)
 609+
 610+	refs, err := repo.ShowRef(git.ShowRefOptions{Heads: true, Tags: true})
 611+	bail(err)
 612+
 613+	var first *RevData
 614+	revs := []*RevData{}
 615+	for _, revStr := range c.Revs {
 616+		fullRevID, err := repo.RevParse(revStr)
 617+		bail(err)
 618+
 619+		revID := getShortID(fullRevID)
 620+		revName := revID
 621+		// if it's a reference then label it as such
 622+		for _, ref := range refs {
 623+			if revStr == git.RefShortName(ref.Refspec) || revStr == ref.Refspec {
 624+				revName = revStr
 625+				break
 626+			}
 627+		}
 628+
 629+		data := &RevData{
 630+			id:     fullRevID,
 631+			name:   revName,
 632+			Config: c,
 633+		}
 634+
 635+		if first == nil {
 636+			first = data
 637+		}
 638+		revs = append(revs, data)
 639+	}
 640+
 641+	if first == nil {
 642+		bail(fmt.Errorf("could find find a git reference that matches criteria"))
 643+	}
 644+
 645+	refInfoMap := map[string]*RefInfo{}
 646+	for _, revData := range revs {
 647+		refInfoMap[revData.Name()] = &RefInfo{
 648+			ID:      revData.ID(),
 649+			Refspec: revData.Name(),
 650+			URL:     revData.TreeURL(),
 651+		}
 652+	}
 653+
 654+	// loop through ALL refs that don't have URLs
 655+	// and add them to the map
 656+	for _, ref := range refs {
 657+		refspec := git.RefShortName(ref.Refspec)
 658+		if refInfoMap[refspec] != nil {
 659+			continue
 660+		}
 661+
 662+		refInfoMap[refspec] = &RefInfo{
 663+			ID:      ref.ID,
 664+			Refspec: refspec,
 665+		}
 666+	}
 667+
 668+	// gather lists of refs to display on refs.html page
 669+	refInfoList := []*RefInfo{}
 670+	for _, val := range refInfoMap {
 671+		refInfoList = append(refInfoList, val)
 672+	}
 673+	sort.Slice(refInfoList, func(i, j int) bool {
 674+		urlI := refInfoList[i].URL
 675+		urlJ := refInfoList[j].URL
 676+		refI := refInfoList[i].Refspec
 677+		refJ := refInfoList[j].Refspec
 678+		if urlI == urlJ {
 679+			return refI < refJ
 680+		}
 681+		return urlI > urlJ
 682+	})
 683+
 684+	// we assume the first revision in the list is the "main" revision which mostly
 685+	// means that's the README we use for the default summary page.
 686+	mainOutput := &BranchOutput{}
 687+	var wg sync.WaitGroup
 688+	for i, revData := range revs {
 689+		c.Logger.Info("writing revision", "revision", revData.Name())
 690+		data := &PageData{
 691+			Repo:     c,
 692+			RevData:  revData,
 693+			SiteURLs: c.getURLs(),
 694+		}
 695+
 696+		if i == 0 {
 697+			branchOutput := c.writeRevision(repo, data, refInfoList)
 698+			mainOutput = branchOutput
 699+		} else {
 700+			wg.Add(1)
 701+			go func() {
 702+				defer wg.Done()
 703+				c.writeRevision(repo, data, refInfoList)
 704+			}()
 705+		}
 706+	}
 707+	wg.Wait()
 708+
 709+	// use the first revision in our list to generate
 710+	// the root summary, logs, and tree the user can click
 711+	revData := &RevData{
 712+		id:     first.ID(),
 713+		name:   first.Name(),
 714+		Config: c,
 715+	}
 716+
 717+	data := &PageData{
 718+		RevData:  revData,
 719+		Repo:     c,
 720+		SiteURLs: c.getURLs(),
 721+	}
 722+	c.writeRefs(data, refInfoList)
 723+	// Convert README markdown to HTML for summary page
 724+	var readmeHTML template.HTML
 725+	if isMarkdownFile(readmeFile(c)) {
 726+		readmeHTML = renderMarkdown(mainOutput.Readme)
 727+	} else {
 728+		readmeHTML = template.HTML(mainOutput.Readme)
 729+	}
 730+	// Wrap README in a div with class for CSS styling
 731+	readmeHTML = template.HTML(`<div class="readme">` + string(readmeHTML) + `</div>`)
 732+	c.writeRootSummary(data, readmeHTML)
 733+	return mainOutput
 734+}
 735+
 736+type TreeRoot struct {
 737+	Path   string
 738+	Items  []*TreeItem
 739+	Crumbs []*Breadcrumb
 740+}
 741+
 742+type TreeWalker struct {
 743+	treeItem           chan *TreeItem
 744+	tree               chan *TreeRoot
 745+	HideTreeLastCommit bool
 746+	PageData           *PageData
 747+	Repo               *git.Repository
 748+	Config             *Config
 749+}
 750+
 751+type Breadcrumb struct {
 752+	Text   string
 753+	URL    template.URL
 754+	IsLast bool
 755+}
 756+
 757+func (tw *TreeWalker) calcBreadcrumbs(curpath string) []*Breadcrumb {
 758+	if curpath == "" {
 759+		return []*Breadcrumb{}
 760+	}
 761+	parts := strings.Split(curpath, string(os.PathSeparator))
 762+	rootURL := tw.Config.compileURL(
 763+		getTreeBaseDir(tw.PageData.RevData),
 764+		"index.html",
 765+	)
 766+
 767+	crumbs := make([]*Breadcrumb, len(parts)+1)
 768+	crumbs[0] = &Breadcrumb{
 769+		URL:  rootURL,
 770+		Text: tw.PageData.Repo.RepoName,
 771+	}
 772+
 773+	cur := ""
 774+	for idx, d := range parts {
 775+		crumb := filepath.Join(getFileBaseDir(tw.PageData.RevData), cur, d)
 776+		crumbUrl := tw.Config.compileURL(crumb, "index.html")
 777+		crumbs[idx+1] = &Breadcrumb{
 778+			Text: d,
 779+			URL:  crumbUrl,
 780+		}
 781+		if idx == len(parts)-1 {
 782+			crumbs[idx+1].IsLast = true
 783+		}
 784+		cur = filepath.Join(cur, d)
 785+	}
 786+
 787+	return crumbs
 788+}
 789+
 790+func filenameToDevIcon(filename string) string {
 791+	ext := filepath.Ext(filename)
 792+	extMappr := map[string]string{
 793+		".html": "html5",
 794+		".go":   "go",
 795+		".py":   "python",
 796+		".css":  "css3",
 797+		".js":   "javascript",
 798+		".md":   "markdown",
 799+		".ts":   "typescript",
 800+		".tsx":  "react",
 801+		".jsx":  "react",
 802+	}
 803+
 804+	nameMappr := map[string]string{
 805+		"Makefile":   "cmake",
 806+		"Dockerfile": "docker",
 807+	}
 808+
 809+	icon := extMappr[ext]
 810+	if icon == "" {
 811+		icon = nameMappr[filename]
 812+	}
 813+
 814+	return fmt.Sprintf("devicon-%s-original", icon)
 815+}
 816+
 817+func (tw *TreeWalker) NewTreeItem(entry *git.TreeEntry, curpath string, crumbs []*Breadcrumb) *TreeItem {
 818+	typ := entry.Type()
 819+	fname := filepath.Join(curpath, entry.Name())
 820+	item := &TreeItem{
 821+		Size:   toPretty(entry.Size()),
 822+		Name:   entry.Name(),
 823+		Path:   fname,
 824+		Entry:  entry,
 825+		URL:    tw.Config.getFileURL(tw.PageData.RevData, fname),
 826+		Crumbs: crumbs,
 827+		Author: &git.Signature{
 828+			Name: "unknown",
 829+		},
 830+	}
 831+
 832+	// `git rev-list` is pretty expensive here, so we have a flag to disable
 833+	if tw.HideTreeLastCommit {
 834+		// c.Logger.Info("skipping the process of finding the last commit for each file")
 835+	} else {
 836+		id := tw.PageData.RevData.ID()
 837+		lastCommits, err := tw.Repo.RevList([]string{id}, git.RevListOptions{
 838+			Path:           item.Path,
 839+			CommandOptions: git.CommandOptions{Args: []string{"-1"}},
 840+		})
 841+		bail(err)
 842+
 843+		if len(lastCommits) > 0 {
 844+			lc := lastCommits[0]
 845+			item.CommitURL = tw.Config.getCommitURL(lc.ID.String())
 846+			item.CommitID = getShortID(lc.ID.String())
 847+			item.Summary = lc.Summary()
 848+			item.When = lc.Author.When.Format(time.DateOnly)
 849+			item.Author = lc.Author
 850+		}
 851+	}
 852+
 853+	fpath := tw.Config.getFileURL(tw.PageData.RevData, fmt.Sprintf("%s.html", fname))
 854+	switch typ {
 855+	case git.ObjectTree:
 856+		item.IsDir = true
 857+		fpath = tw.Config.compileURL(
 858+			filepath.Join(
 859+				getFileBaseDir(tw.PageData.RevData),
 860+				curpath,
 861+				entry.Name(),
 862+			),
 863+			"index.html",
 864+		)
 865+	case git.ObjectBlob:
 866+		item.Icon = filenameToDevIcon(item.Name)
 867+	}
 868+	item.URL = fpath
 869+
 870+	return item
 871+}
 872+
 873+func (tw *TreeWalker) walk(tree *git.Tree, curpath string) {
 874+	entries, err := tree.Entries()
 875+	bail(err)
 876+
 877+	crumbs := tw.calcBreadcrumbs(curpath)
 878+	treeEntries := []*TreeItem{}
 879+	for _, entry := range entries {
 880+		typ := entry.Type()
 881+		item := tw.NewTreeItem(entry, curpath, crumbs)
 882+
 883+		switch typ {
 884+		case git.ObjectTree:
 885+			item.IsDir = true
 886+			re, _ := tree.Subtree(entry.Name())
 887+			tw.walk(re, item.Path)
 888+			treeEntries = append(treeEntries, item)
 889+			tw.treeItem <- item
 890+		case git.ObjectBlob:
 891+			treeEntries = append(treeEntries, item)
 892+			tw.treeItem <- item
 893+		}
 894+	}
 895+
 896+	sort.Slice(treeEntries, func(i, j int) bool {
 897+		nameI := treeEntries[i].Name
 898+		nameJ := treeEntries[j].Name
 899+		if treeEntries[i].IsDir && treeEntries[j].IsDir {
 900+			return nameI < nameJ
 901+		}
 902+
 903+		if treeEntries[i].IsDir && !treeEntries[j].IsDir {
 904+			return true
 905+		}
 906+
 907+		if !treeEntries[i].IsDir && treeEntries[j].IsDir {
 908+			return false
 909+		}
 910+
 911+		return nameI < nameJ
 912+	})
 913+
 914+	fpath := filepath.Join(
 915+		getFileBaseDir(tw.PageData.RevData),
 916+		curpath,
 917+	)
 918+	// root gets a special spot outside of `item` subdir
 919+	if curpath == "" {
 920+		fpath = getTreeBaseDir(tw.PageData.RevData)
 921+	}
 922+
 923+	tw.tree <- &TreeRoot{
 924+		Path:   fpath,
 925+		Items:  treeEntries,
 926+		Crumbs: crumbs,
 927+	}
 928+
 929+	if curpath == "" {
 930+		close(tw.tree)
 931+		close(tw.treeItem)
 932+	}
 933+}
 934+
 935+func (c *Config) writeRevision(repo *git.Repository, pageData *PageData, refs []*RefInfo) *BranchOutput {
 936+	c.Logger.Info(
 937+		"compiling revision",
 938+		"repoName", c.RepoName,
 939+		"revision", pageData.RevData.Name(),
 940+	)
 941+
 942+	output := &BranchOutput{}
 943+
 944+	var wg sync.WaitGroup
 945+
 946+	wg.Add(1)
 947+	go func() {
 948+		defer wg.Done()
 949+
 950+		pageSize := pageData.Repo.MaxCommits
 951+		if pageSize == 0 {
 952+			pageSize = 5000
 953+		}
 954+		commits, err := repo.CommitsByPage(pageData.RevData.ID(), 0, pageSize)
 955+		bail(err)
 956+
 957+		logs := []*CommitData{}
 958+		for i, commit := range commits {
 959+			if i == 0 {
 960+				output.LastCommit = commit
 961+			}
 962+
 963+			tags := []*RefInfo{}
 964+			for _, ref := range refs {
 965+				if commit.ID.String() == ref.ID {
 966+					tags = append(tags, ref)
 967+				}
 968+			}
 969+
 970+			parentSha, _ := commit.ParentID(0)
 971+			parentID := ""
 972+			if parentSha == nil {
 973+				parentID = commit.ID.String()
 974+			} else {
 975+				parentID = parentSha.String()
 976+			}
 977+			logs = append(logs, &CommitData{
 978+				ParentID:   parentID,
 979+				URL:        c.getCommitURL(commit.ID.String()),
 980+				ShortID:    getShortID(commit.ID.String()),
 981+				SummaryStr: commit.Summary(),
 982+				AuthorStr:  commit.Author.Name,
 983+				WhenStr:    commit.Author.When.Format(time.DateOnly),
 984+				Commit:     commit,
 985+				Refs:       tags,
 986+			})
 987+		}
 988+
 989+		c.writeLog(pageData, logs)
 990+
 991+		for _, cm := range logs {
 992+			wg.Add(1)
 993+			go func(commit *CommitData) {
 994+				defer wg.Done()
 995+				c.writeLogDiff(repo, pageData, commit)
 996+			}(cm)
 997+		}
 998+	}()
 999+
1000+	tree, err := repo.LsTree(pageData.RevData.ID())
1001+	bail(err)
1002+
1003+	readme := ""
1004+	entries := make(chan *TreeItem)
1005+	subtrees := make(chan *TreeRoot)
1006+	tw := &TreeWalker{
1007+		Config:   c,
1008+		PageData: pageData,
1009+		Repo:     repo,
1010+		treeItem: entries,
1011+		tree:     subtrees,
1012+	}
1013+	wg.Add(1)
1014+	go func() {
1015+		defer wg.Done()
1016+		tw.walk(tree, "")
1017+	}()
1018+
1019+	wg.Add(1)
1020+	go func() {
1021+		defer wg.Done()
1022+		for e := range entries {
1023+			wg.Add(1)
1024+			go func(entry *TreeItem) {
1025+				defer wg.Done()
1026+				if entry.IsDir {
1027+					return
1028+				}
1029+
1030+				readmeStr := c.writeHTMLTreeFile(pageData, entry)
1031+				if readmeStr != "" {
1032+					readme = readmeStr
1033+				}
1034+			}(e)
1035+		}
1036+	}()
1037+
1038+	wg.Add(1)
1039+	go func() {
1040+		defer wg.Done()
1041+		for t := range subtrees {
1042+			wg.Add(1)
1043+			go func(tree *TreeRoot) {
1044+				defer wg.Done()
1045+				c.writeTree(pageData, tree)
1046+			}(t)
1047+		}
1048+	}()
1049+
1050+	wg.Wait()
1051+
1052+	c.Logger.Info(
1053+		"compilation complete",
1054+		"repoName", c.RepoName,
1055+		"revision", pageData.RevData.Name(),
1056+	)
1057+
1058+	output.Readme = readme
1059+	return output
1060+}
1061+
1062+func style(theme chroma.Style) string {
1063+	bg := theme.Get(chroma.Background)
1064+	txt := theme.Get(chroma.Text)
1065+	kw := theme.Get(chroma.Keyword)
1066+	nv := theme.Get(chroma.NameVariable)
1067+	cm := theme.Get(chroma.Comment)
1068+	ln := theme.Get(chroma.LiteralNumber)
1069+	return fmt.Sprintf(`:root {
1070+  --bg-color: %s;
1071+  --text-color: %s;
1072+  --border: %s;
1073+  --link-color: %s;
1074+  --hover: %s;
1075+  --visited: %s;
1076+}`,
1077+		bg.Background.String(),
1078+		txt.Colour.String(),
1079+		cm.Colour.String(),
1080+		nv.Colour.String(),
1081+		kw.Colour.String(),
1082+		ln.Colour.String(),
1083+	)
1084+}
1085+
1086+func main() {
1087+	var outdir = flag.String("out", "./public", "output directory")
1088+	var rpath = flag.String("repo", ".", "path to git repo")
1089+	var revsFlag = flag.String("revs", "HEAD", "list of revs to generate logs and tree (e.g. main,v1,c69f86f,HEAD)")
1090+	var themeFlag = flag.String("theme", "dracula", "theme to use for site")
1091+	var labelFlag = flag.String("label", "", "pretty name for the subdir where we create the repo, default is last folder in --repo")
1092+	var cloneFlag = flag.String("clone-url", "", "git clone URL for upstream")
1093+	var homeFlag = flag.String("home-url", "", "URL for breadcumbs to go to root page, hidden if empty")
1094+	var descFlag = flag.String("desc", "", "description for repo")
1095+	var rootRelativeFlag = flag.String("root-relative", "/", "html root relative")
1096+	var maxCommitsFlag = flag.Int("max-commits", 0, "maximum number of commits to generate")
1097+	var hideTreeLastCommitFlag = flag.Bool("hide-tree-last-commit", false, "dont calculate last commit for each file in the tree")
1098+
1099+	flag.Parse()
1100+
1101+	out, err := filepath.Abs(*outdir)
1102+	bail(err)
1103+	repoPath, err := filepath.Abs(*rpath)
1104+	bail(err)
1105+
1106+	theme := styles.Get(*themeFlag)
1107+
1108+	logger := slog.Default()
1109+
1110+	label := repoName(repoPath)
1111+	if *labelFlag != "" {
1112+		label = *labelFlag
1113+	}
1114+
1115+	revs := strings.Split(*revsFlag, ",")
1116+	if len(revs) == 1 && revs[0] == "" {
1117+		revs = []string{}
1118+	}
1119+
1120+	formatter := formatterHtml.New(
1121+		formatterHtml.WithLineNumbers(true),
1122+		formatterHtml.WithLinkableLineNumbers(true, ""),
1123+		formatterHtml.WithClasses(true),
1124+	)
1125+
1126+	config := &Config{
1127+		Outdir:             out,
1128+		RepoPath:           repoPath,
1129+		RepoName:           label,
1130+		Cache:              make(map[string]bool),
1131+		Revs:               revs,
1132+		Theme:              theme,
1133+		Logger:             logger,
1134+		CloneURL:           template.URL(*cloneFlag),
1135+		HomeURL:            template.URL(*homeFlag),
1136+		Desc:               *descFlag,
1137+		MaxCommits:         *maxCommitsFlag,
1138+		HideTreeLastCommit: *hideTreeLastCommitFlag,
1139+		RootRelative:       *rootRelativeFlag,
1140+		Formatter:          formatter,
1141+	}
1142+	config.Logger.Info("config", "config", config)
1143+
1144+	if len(revs) == 0 {
1145+		bail(fmt.Errorf("you must provide --revs"))
1146+	}
1147+
1148+	config.writeRepo()
1149+	err = config.copyStatic("static")
1150+	bail(err)
1151+
1152+	styles := style(*theme)
1153+	err = os.WriteFile(filepath.Join(out, "vars.css"), []byte(styles), 0644)
1154+	if err != nil {
1155+		panic(err)
1156+	}
1157+
1158+	fp := filepath.Join(out, "syntax.css")
1159+	w, err := os.OpenFile(fp, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
1160+	if err != nil {
1161+		bail(err)
1162+	}
1163+	err = formatter.WriteCSS(w, theme)
1164+	if err != nil {
1165+		bail(err)
1166+	}
1167+
1168+	url := filepath.Join("/", "index.html")
1169+	config.Logger.Info("root url", "url", url)
1170+}
A static/_pgs_ignore
+1, -0
1@@ -0,0 +1 @@
2+# dont ignore any files
A static/main.css
+54, -0
 1@@ -0,0 +1,54 @@
 2+pre {
 3+  border: 1px solid var(--border);
 4+  padding: var(--grid-height);
 5+}
 6+
 7+body {
 8+  max-width: 900px;
 9+}
10+
11+.border-b {
12+  border-bottom: 1px solid var(--border);
13+}
14+
15+.border-b:last-child {
16+  border-bottom: 0;
17+}
18+
19+.box {
20+  margin: 1rem 0;
21+  padding: var(--grid-height);
22+  border: 1px solid var(--border);
23+}
24+
25+.tree-size {
26+  width: 60px;
27+  text-align: right;
28+}
29+
30+.tree-path {
31+  text-wrap: wrap;
32+}
33+
34+.diff-file {
35+  align-items: center;
36+  height: 62px;
37+  position: sticky;
38+  top: 0;
39+  left: 0;
40+  background-color: var(--bg-color);
41+}
42+
43+.white-space-bs {
44+  white-space: break-spaces;
45+}
46+
47+.mb-0 {
48+  margin-bottom: 0;
49+}
50+
51+@media only screen and (max-width: 900px) {
52+  .tree-commit {
53+    display: none;
54+  }
55+}
A static/smol.css
+761, -0
  1@@ -0,0 +1,761 @@
  2+*,
  3+::before,
  4+::after {
  5+  box-sizing: border-box;
  6+}
  7+
  8+::-moz-focus-inner {
  9+  border-style: none;
 10+  padding: 0;
 11+}
 12+:-moz-focusring {
 13+  outline: 1px dotted ButtonText;
 14+}
 15+:-moz-ui-invalid {
 16+  box-shadow: none;
 17+}
 18+
 19+:root {
 20+  --line-height: 1.3rem;
 21+  --grid-height: 0.65rem;
 22+}
 23+
 24+@media (prefers-color-scheme: light) {
 25+  :root {
 26+    --white: #2e3f53;
 27+    --white-light: #cfe0f4;
 28+    --white-dark: #6c6a6a;
 29+    --code: #52576f;
 30+    --pre: #e1e7ee;
 31+    --bg-color: #f4f4f4;
 32+    --text-color: #24292f;
 33+    --link-color: #005cc5;
 34+    --visited: #6f42c1;
 35+    --blockquote: #005cc5;
 36+    --blockquote-bg: #cfe0f4;
 37+    --hover: #c11e7a;
 38+    --grey: #ccc;
 39+    --grey-light: #6a708e;
 40+  }
 41+}
 42+
 43+@media (prefers-color-scheme: dark) {
 44+  :root {
 45+    --white: #f2f2f2;
 46+    --white-light: #f2f2f2;
 47+    --white-dark: #e8e8e8;
 48+    --code: #414558;
 49+    --pre: #252525;
 50+    --bg-color: #282a36;
 51+    --text-color: #f2f2f2;
 52+    --link-color: #8be9fd;
 53+    --visited: #bd93f9;
 54+    --blockquote: #bd93f9;
 55+    --blockquote-bg: #353548;
 56+    --hover: #ff80bf;
 57+    --grey: #414558;
 58+    --grey-light: #6a708e;
 59+  }
 60+}
 61+
 62+html {
 63+  background-color: var(--bg-color);
 64+  color: var(--text-color);
 65+  font-size: 16px;
 66+  line-height: var(--line-height);
 67+  font-family:
 68+    -apple-system,
 69+    BlinkMacSystemFont,
 70+    "Segoe UI",
 71+    Roboto,
 72+    Oxygen,
 73+    Ubuntu,
 74+    Cantarell,
 75+    "Fira Sans",
 76+    "Droid Sans",
 77+    "Helvetica Neue",
 78+    Arial,
 79+    sans-serif,
 80+    "Apple Color Emoji",
 81+    "Segoe UI Emoji",
 82+    "Segoe UI Symbol";
 83+  -webkit-text-size-adjust: 100%;
 84+  -moz-tab-size: 4;
 85+  -o-tab-size: 4;
 86+  tab-size: 4;
 87+}
 88+
 89+body {
 90+  margin: 0 auto;
 91+}
 92+
 93+img {
 94+  max-width: 100%;
 95+  height: auto;
 96+}
 97+
 98+b,
 99+strong {
100+  font-weight: bold;
101+}
102+
103+code,
104+kbd,
105+samp,
106+pre {
107+  font-family: monospace;
108+}
109+
110+code,
111+kbd,
112+samp {
113+  border: 2px solid var(--code);
114+}
115+
116+pre > code {
117+  background-color: inherit;
118+  padding: 0;
119+  border: none;
120+  border-radius: 0;
121+}
122+
123+code {
124+  font-size: 90%;
125+  border-radius: 0.3rem;
126+  padding: 0.025rem 0.3rem;
127+}
128+
129+pre {
130+  font-size: 0.8rem;
131+  border-radius: 1px;
132+  padding: var(--line-height);
133+  overflow-x: auto;
134+  background-color: var(--pre) !important;
135+}
136+
137+small {
138+  font-size: 0.8rem;
139+}
140+
141+details {
142+  border: 2px solid var(--grey-light);
143+  padding: calc(var(--grid-height) - 2px) 1ch;
144+  margin-bottom: var(--grid-height);
145+}
146+
147+details[open] summary {
148+  margin-bottom: var(--grid-height);
149+}
150+
151+summary {
152+  display: list-item;
153+  cursor: pointer;
154+}
155+
156+h1,
157+h2,
158+h3,
159+h4 {
160+  margin: 0;
161+  padding: 0;
162+  border: 0;
163+  font-style: normal;
164+  font-weight: inherit;
165+  font-size: inherit;
166+  font-family: monospace;
167+}
168+
169+path {
170+  fill: var(--text-color);
171+  stroke: var(--text-color);
172+}
173+
174+hr {
175+  color: inherit;
176+  border: 0;
177+  height: 2px;
178+  background: var(--grey);
179+  margin: calc(var(--grid-height) - 2px) auto;
180+}
181+
182+a {
183+  text-decoration: none;
184+  color: var(--link-color);
185+}
186+
187+a:hover,
188+a:visited:hover {
189+  text-decoration: underline;
190+}
191+
192+a:visited {
193+  color: var(--visited);
194+}
195+
196+section {
197+  margin-bottom: 1.4rem;
198+}
199+
200+section:last-child {
201+  margin-bottom: 0;
202+}
203+
204+header {
205+  margin: 1rem auto;
206+}
207+
208+p {
209+  margin-top: var(--line-height);
210+  margin-bottom: var(--line-height);
211+}
212+
213+article {
214+  overflow-wrap: break-word;
215+}
216+
217+blockquote {
218+  border-left: 5px solid var(--blockquote);
219+  background-color: var(--blockquote-bg);
220+  padding: var(--grid-height);
221+  margin: var(--line-height) 0;
222+}
223+
224+blockquote > p {
225+  margin: 0;
226+}
227+
228+blockquote code {
229+  border: 1px solid var(--blockquote);
230+}
231+
232+ul,
233+ol {
234+  padding: 0 15px;
235+  list-style-position: inside;
236+  list-style-type: square;
237+  margin: var(--grid-height) 0;
238+}
239+
240+ul[style*="list-style-type: none;"] {
241+  padding: 0;
242+}
243+
244+ol ul, ol ol, ul ol, ul ul {
245+  padding: 0 0 0 var(--line-height);
246+  margin: 0;
247+}
248+
249+li {
250+  margin: var(--grid-height) 0;
251+  padding: 0;
252+}
253+
254+li::marker {
255+  line-height: 0;
256+  color: var(--grey-light);
257+}
258+
259+li blockquote,
260+li pre {
261+  display: inline-block;
262+  margin: 0;
263+  width: 100%;
264+}
265+
266+li pre {
267+  padding: var(--grid-height);
268+}
269+
270+[role="list"] {
271+  display: flex;
272+  flex-direction: column;
273+  gap: var(--grid-height);
274+}
275+
276+[role="list"] [role="list"] {
277+  margin-left: 1.3rem;
278+}
279+
280+[role="listitem"] {
281+  display: flex;
282+  align-items: flex-start;
283+  gap: 5px;
284+}
285+
286+[role="listitem"]:has(pre, img, blockquote) {
287+  align-items: center;
288+}
289+
290+table {
291+  border-collapse: collapse;
292+  margin: var(--line-height) 0;
293+}
294+
295+th,
296+td {
297+  border: 1px solid var(--white);
298+  padding: var(--grid-height);
299+}
300+
301+footer {
302+  text-align: center;
303+  margin-bottom: calc(var(--line-height) * 3);
304+}
305+
306+dt {
307+  font-weight: bold;
308+}
309+
310+dd {
311+  margin-left: 0;
312+}
313+
314+dd:not(:last-child) {
315+  margin-bottom: 0.5rem;
316+}
317+
318+figure {
319+  margin: 0;
320+}
321+
322+sup {
323+  line-height: 0;
324+}
325+
326+#toc {
327+  margin-top: var(--line-height);
328+}
329+
330+.container {
331+  max-width: 50em;
332+  width: 100%;
333+}
334+
335+.container-sm {
336+  max-width: 40em;
337+  width: 100%;
338+}
339+
340+.mono {
341+  font-family: monospace;
342+}
343+
344+.link-alt-hover,
345+.link-alt-hover:visited,
346+.link-alt-hover:visited:hover,
347+.link-alt-hover:hover {
348+  color: var(--hover);
349+  text-decoration: none;
350+}
351+
352+.link-alt-hover:visited:hover,
353+.link-alt-hover:hover {
354+  text-decoration: underline;
355+}
356+
357+.link-alt,
358+.link-alt:visited,
359+.link-alt:visited:hover,
360+.link-alt:hover {
361+  color: var(--white);
362+  text-decoration: none;
363+}
364+
365+.link-alt:visited:hover,
366+.link-alt:hover {
367+  text-decoration: underline;
368+}
369+
370+.text-2xl code, .text-xl code, .text-lg code, .text-md code {
371+  text-transform: none;
372+}
373+
374+.text-2xl {
375+  font-size: var(--line-height);
376+  font-weight: bold;
377+  line-height: var(--line-height);
378+  margin-bottom: var(--grid-height);
379+  text-transform: uppercase;
380+}
381+
382+.text-xl, .text-lg, .text-md {
383+  font-size: 1rem;
384+  font-weight: bold;
385+  line-height: var(--line-height);
386+  margin-bottom: var(--grid-height);
387+  text-transform: uppercase;
388+}
389+
390+.text-sm {
391+  font-size: 0.8rem;
392+}
393+
394+.w-full {
395+  width: 100%;
396+}
397+
398+.border {
399+  border: 2px solid var(--grey-light);
400+}
401+
402+.text-left {
403+  text-align: left;
404+}
405+
406+.text-center {
407+  text-align: center;
408+}
409+
410+.text-underline {
411+  text-decoration: underline;
412+  text-decoration-thickness: 2px;
413+}
414+
415+.text-hdr {
416+  color: var(--hover);
417+}
418+
419+.font-bold {
420+  font-weight: bold;
421+}
422+
423+.font-italic {
424+  font-style: italic;
425+}
426+
427+.inline {
428+  display: inline;
429+}
430+
431+.inline-block {
432+  display: inline-block;
433+}
434+
435+.max-w-half {
436+  max-width: 50%;
437+}
438+
439+.flex {
440+  display: flex;
441+}
442+
443+.flex-col {
444+  flex-direction: column;
445+}
446+
447+.flex-wrap {
448+  flex-wrap: wrap;
449+}
450+
451+.items-center {
452+  align-items: center;
453+}
454+
455+.m-0 {
456+  margin: 0;
457+}
458+
459+.mt-0 {
460+  margin-top: 0;
461+}
462+
463+.mt {
464+  margin-top: var(--grid-height);
465+}
466+
467+.mt-2 {
468+  margin-top: var(--line-height);
469+}
470+
471+.mt-4 {
472+  margin-top: calc(var(--line-height) * 2);
473+}
474+
475+.mb {
476+  margin-bottom: var(--grid-height);
477+}
478+
479+.mb-2 {
480+  margin-bottom: var(--line-height);
481+}
482+
483+.mb-4 {
484+  margin-bottom: calc(var(--line-height) * 2);
485+}
486+
487+.mr {
488+  margin-right: 0.5rem;
489+}
490+
491+.ml {
492+  margin-left: 0.5rem;
493+}
494+
495+.my {
496+  margin-top: var(--grid-height);
497+  margin-bottom: var(--grid-height);
498+}
499+
500+.my-2 {
501+  margin-top: var(--line-height);
502+  margin-bottom: var(--line-height);
503+}
504+
505+.my-4 {
506+  margin-top: calc(var(--line-height) * 2);
507+  margin-bottom: calc(var(--line-height) * 2);
508+}
509+
510+.p-0 {
511+  padding: 0;
512+}
513+
514+.px {
515+  padding-left: 0.5rem;
516+  padding-right: 0.5rem;
517+}
518+
519+.px-4 {
520+  padding-left: 2rem;
521+  padding-right: 2rem;
522+}
523+
524+.py {
525+  padding-top: var(--grid-height);
526+  padding-bottom: var(--grid-height);
527+}
528+
529+.py-4 {
530+  padding-top: calc(var(--line-height) * 2);
531+  padding-bottom: calc(var(--line-height) * 2);
532+}
533+
534+.justify-between {
535+  justify-content: space-between;
536+}
537+
538+.justify-center {
539+  justify-content: center;
540+}
541+
542+.gap {
543+  gap: var(--grid-height);
544+}
545+
546+.gap-2 {
547+  gap: var(--line-height);
548+}
549+
550+.group {
551+  display: flex;
552+  flex-direction: column;
553+  gap: var(--grid-height);
554+}
555+
556+.group-2 {
557+  display: flex;
558+  flex-direction: column;
559+  gap: var(--line-height);
560+}
561+
562+.group-h {
563+  display: flex;
564+  gap: var(--grid-height);
565+  align-items: center;
566+}
567+
568+.flex-1 {
569+  flex: 1;
570+}
571+
572+.items-end {
573+  align-items: end;
574+}
575+
576+.items-start {
577+  align-items: start;
578+}
579+
580+.justify-end {
581+  justify-content: end;
582+}
583+
584+.font-grey-light {
585+  color: var(--grey-light);
586+}
587+
588+.hidden {
589+  display: none;
590+}
591+
592+.align-right {
593+  text-align: right;
594+}
595+
596+.text-transform-none {
597+  text-transform: none;
598+}
599+
600+/* ==== MARKDOWN ==== */
601+
602+.md h1,
603+.md h2,
604+.md h3,
605+.md h4 {
606+  padding: 0;
607+  margin: 0;
608+  /* margin: 1.5rem 0 0.9rem 0; */
609+  font-weight: bold;
610+}
611+
612+.md h1 a,
613+.md h2 a,
614+.md h3 a,
615+.md h4 a {
616+  color: var(--grey-light);
617+  text-decoration: none;
618+}
619+
620+h1 code, h2 code, h3 code, h4 code {
621+  text-transform: none;
622+}
623+
624+.md h1 {
625+  font-size: 1rem;
626+  line-height: var(--line-height);
627+  margin-top: calc(var(--line-height) * 2);
628+  margin-bottom: var(--grid-height);
629+  text-transform: uppercase;
630+}
631+
632+.md h2, .md h3, .md h4 {
633+  font-size: 1rem;
634+  line-height: var(--line-height);
635+  margin-top: calc(var(--line-height) * 2);
636+  margin-bottom: var(--line-height);
637+  text-transform: uppercase;
638+  color: var(--white-dark);
639+}
640+
641+/* ==== HELPERS ==== */
642+
643+.logo-header {
644+  line-height: 1;
645+  display: inline-block;
646+  background-color: #ff79c6;
647+  background-image: linear-gradient(to right, #ff5555, #ff79c6, #f8f859);
648+  color: transparent;
649+  background-clip: text;
650+  border: 3px solid #ff79c6;
651+  padding: 8px 10px 10px 10px;
652+  border-radius: 10px;
653+  background-size: 100%;
654+  margin: 0;
655+  -webkit-background-clip: text;
656+  -moz-background-clip: text;
657+  -webkit-text-fill-color: transparent;
658+  -moz-text-fill-color: transparent;
659+}
660+
661+.btn {
662+  border: 2px solid var(--link-color);
663+  color: var(--link-color);
664+  padding: 0.4rem 1rem;
665+  font-weight: bold;
666+  display: inline-block;
667+}
668+
669+.btn-link,
670+.btn-link:visited {
671+  border: 2px solid var(--link-color);
672+  color: var(--link-color);
673+  padding: var(--grid-height);
674+  text-decoration: none;
675+  font-weight: bold;
676+  display: inline-block;
677+}
678+
679+.box {
680+  border: 2px solid var(--grey-light);
681+  padding: var(--grid-height);
682+}
683+
684+.box-sm {
685+  border: 2px solid var(--grey-light);
686+  padding: var(--grid-height);
687+}
688+
689+.box-alert {
690+  border: 2px solid var(--hover);
691+  padding: var(--line-height);
692+}
693+
694+.box-sm-alert {
695+  border: 2px solid var(--hover);
696+  padding: var(--grid-height);
697+}
698+
699+.list-none {
700+  list-style-type: none;
701+}
702+
703+.list-square {
704+  list-style-type: square;
705+}
706+
707+.list-disc {
708+  list-style-type: disc;
709+}
710+
711+.list-decimal {
712+  list-style-type: decimal;
713+}
714+
715+.pill {
716+  border: 1px solid var(--link-color);
717+  color: var(--link-color);
718+}
719+
720+.pill-alert {
721+  border: 1px solid var(--hover);
722+  color: var(--hover);
723+}
724+
725+.pill-info {
726+  border: 1px solid var(--visited);
727+  color: var(--visited);
728+}
729+
730+@media only screen and (max-width: 40em) {
731+  body {
732+    padding: 0 var(--grid-height);
733+  }
734+
735+  header {
736+    margin: 0;
737+  }
738+
739+  .flex-collapse {
740+    flex-direction: column;
741+  }
742+}
743+
744+#debug {
745+  position: relative;
746+}
747+
748+#debug .debug-grid {
749+  width: 100%;
750+  height: 100%;
751+  position: absolute;
752+  top: 0;
753+  left: 0;
754+  right: 0;
755+  bottom: 0;
756+  z-index: -1;
757+  background-image:
758+    repeating-linear-gradient(var(--code) 0 1px, transparent 1px 100%),
759+    repeating-linear-gradient(90deg, var(--code) 0 1px, transparent 1px 100%);
760+  background-size: 1ch var(--grid-height);
761+  margin: 0;
762+}
A testdata/HEAD
+1, -0
1@@ -0,0 +1 @@
2+ref: refs/heads/main
A testdata/config
+4, -0
1@@ -0,0 +1,4 @@
2+[core]
3+	repositoryformatversion = 0
4+	filemode = true
5+	bare = true
A testdata/description
+1, -0
1@@ -0,0 +1 @@
2+Unnamed repository; edit this file 'description' to name the repository.
A testdata/hooks/applypatch-msg.sample
+15, -0
 1@@ -0,0 +1,15 @@
 2+#!/bin/sh
 3+#
 4+# An example hook script to check the commit log message taken by
 5+# applypatch from an e-mail message.
 6+#
 7+# The hook should exit with non-zero status after issuing an
 8+# appropriate message if it wants to stop the commit.  The hook is
 9+# allowed to edit the commit message file.
10+#
11+# To enable this hook, rename this file to "applypatch-msg".
12+
13+. git-sh-setup
14+commitmsg="$(git rev-parse --git-path hooks/commit-msg)"
15+test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"}
16+:
A testdata/hooks/commit-msg.sample
+24, -0
 1@@ -0,0 +1,24 @@
 2+#!/bin/sh
 3+#
 4+# An example hook script to check the commit log message.
 5+# Called by "git commit" with one argument, the name of the file
 6+# that has the commit message.  The hook should exit with non-zero
 7+# status after issuing an appropriate message if it wants to stop the
 8+# commit.  The hook is allowed to edit the commit message file.
 9+#
10+# To enable this hook, rename this file to "commit-msg".
11+
12+# Uncomment the below to add a Signed-off-by line to the message.
13+# Doing this in a hook is a bad idea in general, but the prepare-commit-msg
14+# hook is more suited to it.
15+#
16+# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
17+# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1"
18+
19+# This example catches duplicate Signed-off-by lines.
20+
21+test "" = "$(grep '^Signed-off-by: ' "$1" |
22+	 sort | uniq -c | sed -e '/^[ 	]*1[ 	]/d')" || {
23+	echo >&2 Duplicate Signed-off-by lines.
24+	exit 1
25+}
A testdata/hooks/fsmonitor-watchman.sample
+174, -0
  1@@ -0,0 +1,174 @@
  2+#!/usr/bin/perl
  3+
  4+use strict;
  5+use warnings;
  6+use IPC::Open2;
  7+
  8+# An example hook script to integrate Watchman
  9+# (https://facebook.github.io/watchman/) with git to speed up detecting
 10+# new and modified files.
 11+#
 12+# The hook is passed a version (currently 2) and last update token
 13+# formatted as a string and outputs to stdout a new update token and
 14+# all files that have been modified since the update token. Paths must
 15+# be relative to the root of the working tree and separated by a single NUL.
 16+#
 17+# To enable this hook, rename this file to "query-watchman" and set
 18+# 'git config core.fsmonitor .git/hooks/query-watchman'
 19+#
 20+my ($version, $last_update_token) = @ARGV;
 21+
 22+# Uncomment for debugging
 23+# print STDERR "$0 $version $last_update_token\n";
 24+
 25+# Check the hook interface version
 26+if ($version ne 2) {
 27+	die "Unsupported query-fsmonitor hook version '$version'.\n" .
 28+	    "Falling back to scanning...\n";
 29+}
 30+
 31+my $git_work_tree = get_working_dir();
 32+
 33+my $retry = 1;
 34+
 35+my $json_pkg;
 36+eval {
 37+	require JSON::XS;
 38+	$json_pkg = "JSON::XS";
 39+	1;
 40+} or do {
 41+	require JSON::PP;
 42+	$json_pkg = "JSON::PP";
 43+};
 44+
 45+launch_watchman();
 46+
 47+sub launch_watchman {
 48+	my $o = watchman_query();
 49+	if (is_work_tree_watched($o)) {
 50+		output_result($o->{clock}, @{$o->{files}});
 51+	}
 52+}
 53+
 54+sub output_result {
 55+	my ($clockid, @files) = @_;
 56+
 57+	# Uncomment for debugging watchman output
 58+	# open (my $fh, ">", ".git/watchman-output.out");
 59+	# binmode $fh, ":utf8";
 60+	# print $fh "$clockid\n@files\n";
 61+	# close $fh;
 62+
 63+	binmode STDOUT, ":utf8";
 64+	print $clockid;
 65+	print "\0";
 66+	local $, = "\0";
 67+	print @files;
 68+}
 69+
 70+sub watchman_clock {
 71+	my $response = qx/watchman clock "$git_work_tree"/;
 72+	die "Failed to get clock id on '$git_work_tree'.\n" .
 73+		"Falling back to scanning...\n" if $? != 0;
 74+
 75+	return $json_pkg->new->utf8->decode($response);
 76+}
 77+
 78+sub watchman_query {
 79+	my $pid = open2(\*CHLD_OUT, \*CHLD_IN, 'watchman -j --no-pretty')
 80+	or die "open2() failed: $!\n" .
 81+	"Falling back to scanning...\n";
 82+
 83+	# In the query expression below we're asking for names of files that
 84+	# changed since $last_update_token but not from the .git folder.
 85+	#
 86+	# To accomplish this, we're using the "since" generator to use the
 87+	# recency index to select candidate nodes and "fields" to limit the
 88+	# output to file names only. Then we're using the "expression" term to
 89+	# further constrain the results.
 90+	my $last_update_line = "";
 91+	if (substr($last_update_token, 0, 1) eq "c") {
 92+		$last_update_token = "\"$last_update_token\"";
 93+		$last_update_line = qq[\n"since": $last_update_token,];
 94+	}
 95+	my $query = <<"	END";
 96+		["query", "$git_work_tree", {$last_update_line
 97+			"fields": ["name"],
 98+			"expression": ["not", ["dirname", ".git"]]
 99+		}]
100+	END
101+
102+	# Uncomment for debugging the watchman query
103+	# open (my $fh, ">", ".git/watchman-query.json");
104+	# print $fh $query;
105+	# close $fh;
106+
107+	print CHLD_IN $query;
108+	close CHLD_IN;
109+	my $response = do {local $/; <CHLD_OUT>};
110+
111+	# Uncomment for debugging the watch response
112+	# open ($fh, ">", ".git/watchman-response.json");
113+	# print $fh $response;
114+	# close $fh;
115+
116+	die "Watchman: command returned no output.\n" .
117+	"Falling back to scanning...\n" if $response eq "";
118+	die "Watchman: command returned invalid output: $response\n" .
119+	"Falling back to scanning...\n" unless $response =~ /^\{/;
120+
121+	return $json_pkg->new->utf8->decode($response);
122+}
123+
124+sub is_work_tree_watched {
125+	my ($output) = @_;
126+	my $error = $output->{error};
127+	if ($retry > 0 and $error and $error =~ m/unable to resolve root .* directory (.*) is not watched/) {
128+		$retry--;
129+		my $response = qx/watchman watch "$git_work_tree"/;
130+		die "Failed to make watchman watch '$git_work_tree'.\n" .
131+		    "Falling back to scanning...\n" if $? != 0;
132+		$output = $json_pkg->new->utf8->decode($response);
133+		$error = $output->{error};
134+		die "Watchman: $error.\n" .
135+		"Falling back to scanning...\n" if $error;
136+
137+		# Uncomment for debugging watchman output
138+		# open (my $fh, ">", ".git/watchman-output.out");
139+		# close $fh;
140+
141+		# Watchman will always return all files on the first query so
142+		# return the fast "everything is dirty" flag to git and do the
143+		# Watchman query just to get it over with now so we won't pay
144+		# the cost in git to look up each individual file.
145+		my $o = watchman_clock();
146+		$error = $output->{error};
147+
148+		die "Watchman: $error.\n" .
149+		"Falling back to scanning...\n" if $error;
150+
151+		output_result($o->{clock}, ("/"));
152+		$last_update_token = $o->{clock};
153+
154+		eval { launch_watchman() };
155+		return 0;
156+	}
157+
158+	die "Watchman: $error.\n" .
159+	"Falling back to scanning...\n" if $error;
160+
161+	return 1;
162+}
163+
164+sub get_working_dir {
165+	my $working_dir;
166+	if ($^O =~ 'msys' || $^O =~ 'cygwin') {
167+		$working_dir = Win32::GetCwd();
168+		$working_dir =~ tr/\\/\//;
169+	} else {
170+		require Cwd;
171+		$working_dir = Cwd::cwd();
172+	}
173+
174+	return $working_dir;
175+}
A testdata/hooks/post-update.sample
+8, -0
1@@ -0,0 +1,8 @@
2+#!/bin/sh
3+#
4+# An example hook script to prepare a packed repository for use over
5+# dumb transports.
6+#
7+# To enable this hook, rename this file to "post-update".
8+
9+exec git update-server-info
A testdata/hooks/pre-applypatch.sample
+14, -0
 1@@ -0,0 +1,14 @@
 2+#!/bin/sh
 3+#
 4+# An example hook script to verify what is about to be committed
 5+# by applypatch from an e-mail message.
 6+#
 7+# The hook should exit with non-zero status after issuing an
 8+# appropriate message if it wants to stop the commit.
 9+#
10+# To enable this hook, rename this file to "pre-applypatch".
11+
12+. git-sh-setup
13+precommit="$(git rev-parse --git-path hooks/pre-commit)"
14+test -x "$precommit" && exec "$precommit" ${1+"$@"}
15+:
A testdata/hooks/pre-commit.sample
+49, -0
 1@@ -0,0 +1,49 @@
 2+#!/bin/sh
 3+#
 4+# An example hook script to verify what is about to be committed.
 5+# Called by "git commit" with no arguments.  The hook should
 6+# exit with non-zero status after issuing an appropriate message if
 7+# it wants to stop the commit.
 8+#
 9+# To enable this hook, rename this file to "pre-commit".
10+
11+if git rev-parse --verify HEAD >/dev/null 2>&1
12+then
13+	against=HEAD
14+else
15+	# Initial commit: diff against an empty tree object
16+	against=$(git hash-object -t tree /dev/null)
17+fi
18+
19+# If you want to allow non-ASCII filenames set this variable to true.
20+allownonascii=$(git config --type=bool hooks.allownonascii)
21+
22+# Redirect output to stderr.
23+exec 1>&2
24+
25+# Cross platform projects tend to avoid non-ASCII filenames; prevent
26+# them from being added to the repository. We exploit the fact that the
27+# printable range starts at the space character and ends with tilde.
28+if [ "$allownonascii" != "true" ] &&
29+	# Note that the use of brackets around a tr range is ok here, (it's
30+	# even required, for portability to Solaris 10's /usr/bin/tr), since
31+	# the square bracket bytes happen to fall in the designated range.
32+	test $(git diff-index --cached --name-only --diff-filter=A -z $against |
33+	  LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0
34+then
35+	cat <<\EOF
36+Error: Attempt to add a non-ASCII file name.
37+
38+This can cause problems if you want to work with people on other platforms.
39+
40+To be portable it is advisable to rename the file.
41+
42+If you know what you are doing you can disable this check using:
43+
44+  git config hooks.allownonascii true
45+EOF
46+	exit 1
47+fi
48+
49+# If there are whitespace errors, print the offending file names and fail.
50+exec git diff-index --check --cached $against --
A testdata/hooks/pre-merge-commit.sample
+13, -0
 1@@ -0,0 +1,13 @@
 2+#!/bin/sh
 3+#
 4+# An example hook script to verify what is about to be committed.
 5+# Called by "git merge" with no arguments.  The hook should
 6+# exit with non-zero status after issuing an appropriate message to
 7+# stderr if it wants to stop the merge commit.
 8+#
 9+# To enable this hook, rename this file to "pre-merge-commit".
10+
11+. git-sh-setup
12+test -x "$GIT_DIR/hooks/pre-commit" &&
13+        exec "$GIT_DIR/hooks/pre-commit"
14+:
A testdata/hooks/pre-push.sample
+53, -0
 1@@ -0,0 +1,53 @@
 2+#!/bin/sh
 3+
 4+# An example hook script to verify what is about to be pushed.  Called by "git
 5+# push" after it has checked the remote status, but before anything has been
 6+# pushed.  If this script exits with a non-zero status nothing will be pushed.
 7+#
 8+# This hook is called with the following parameters:
 9+#
10+# $1 -- Name of the remote to which the push is being done
11+# $2 -- URL to which the push is being done
12+#
13+# If pushing without using a named remote those arguments will be equal.
14+#
15+# Information about the commits which are being pushed is supplied as lines to
16+# the standard input in the form:
17+#
18+#   <local ref> <local oid> <remote ref> <remote oid>
19+#
20+# This sample shows how to prevent push of commits where the log message starts
21+# with "WIP" (work in progress).
22+
23+remote="$1"
24+url="$2"
25+
26+zero=$(git hash-object --stdin </dev/null | tr '[0-9a-f]' '0')
27+
28+while read local_ref local_oid remote_ref remote_oid
29+do
30+	if test "$local_oid" = "$zero"
31+	then
32+		# Handle delete
33+		:
34+	else
35+		if test "$remote_oid" = "$zero"
36+		then
37+			# New branch, examine all commits
38+			range="$local_oid"
39+		else
40+			# Update to existing branch, examine new commits
41+			range="$remote_oid..$local_oid"
42+		fi
43+
44+		# Check for WIP commit
45+		commit=$(git rev-list -n 1 --grep '^WIP' "$range")
46+		if test -n "$commit"
47+		then
48+			echo >&2 "Found WIP commit in $local_ref, not pushing"
49+			exit 1
50+		fi
51+	fi
52+done
53+
54+exit 0
A testdata/hooks/pre-rebase.sample
+169, -0
  1@@ -0,0 +1,169 @@
  2+#!/bin/sh
  3+#
  4+# Copyright (c) 2006, 2008 Junio C Hamano
  5+#
  6+# The "pre-rebase" hook is run just before "git rebase" starts doing
  7+# its job, and can prevent the command from running by exiting with
  8+# non-zero status.
  9+#
 10+# The hook is called with the following parameters:
 11+#
 12+# $1 -- the upstream the series was forked from.
 13+# $2 -- the branch being rebased (or empty when rebasing the current branch).
 14+#
 15+# This sample shows how to prevent topic branches that are already
 16+# merged to 'next' branch from getting rebased, because allowing it
 17+# would result in rebasing already published history.
 18+
 19+publish=next
 20+basebranch="$1"
 21+if test "$#" = 2
 22+then
 23+	topic="refs/heads/$2"
 24+else
 25+	topic=`git symbolic-ref HEAD` ||
 26+	exit 0 ;# we do not interrupt rebasing detached HEAD
 27+fi
 28+
 29+case "$topic" in
 30+refs/heads/??/*)
 31+	;;
 32+*)
 33+	exit 0 ;# we do not interrupt others.
 34+	;;
 35+esac
 36+
 37+# Now we are dealing with a topic branch being rebased
 38+# on top of master.  Is it OK to rebase it?
 39+
 40+# Does the topic really exist?
 41+git show-ref -q "$topic" || {
 42+	echo >&2 "No such branch $topic"
 43+	exit 1
 44+}
 45+
 46+# Is topic fully merged to master?
 47+not_in_master=`git rev-list --pretty=oneline ^master "$topic"`
 48+if test -z "$not_in_master"
 49+then
 50+	echo >&2 "$topic is fully merged to master; better remove it."
 51+	exit 1 ;# we could allow it, but there is no point.
 52+fi
 53+
 54+# Is topic ever merged to next?  If so you should not be rebasing it.
 55+only_next_1=`git rev-list ^master "^$topic" ${publish} | sort`
 56+only_next_2=`git rev-list ^master           ${publish} | sort`
 57+if test "$only_next_1" = "$only_next_2"
 58+then
 59+	not_in_topic=`git rev-list "^$topic" master`
 60+	if test -z "$not_in_topic"
 61+	then
 62+		echo >&2 "$topic is already up to date with master"
 63+		exit 1 ;# we could allow it, but there is no point.
 64+	else
 65+		exit 0
 66+	fi
 67+else
 68+	not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"`
 69+	/usr/bin/perl -e '
 70+		my $topic = $ARGV[0];
 71+		my $msg = "* $topic has commits already merged to public branch:\n";
 72+		my (%not_in_next) = map {
 73+			/^([0-9a-f]+) /;
 74+			($1 => 1);
 75+		} split(/\n/, $ARGV[1]);
 76+		for my $elem (map {
 77+				/^([0-9a-f]+) (.*)$/;
 78+				[$1 => $2];
 79+			} split(/\n/, $ARGV[2])) {
 80+			if (!exists $not_in_next{$elem->[0]}) {
 81+				if ($msg) {
 82+					print STDERR $msg;
 83+					undef $msg;
 84+				}
 85+				print STDERR " $elem->[1]\n";
 86+			}
 87+		}
 88+	' "$topic" "$not_in_next" "$not_in_master"
 89+	exit 1
 90+fi
 91+
 92+<<\DOC_END
 93+
 94+This sample hook safeguards topic branches that have been
 95+published from being rewound.
 96+
 97+The workflow assumed here is:
 98+
 99+ * Once a topic branch forks from "master", "master" is never
100+   merged into it again (either directly or indirectly).
101+
102+ * Once a topic branch is fully cooked and merged into "master",
103+   it is deleted.  If you need to build on top of it to correct
104+   earlier mistakes, a new topic branch is created by forking at
105+   the tip of the "master".  This is not strictly necessary, but
106+   it makes it easier to keep your history simple.
107+
108+ * Whenever you need to test or publish your changes to topic
109+   branches, merge them into "next" branch.
110+
111+The script, being an example, hardcodes the publish branch name
112+to be "next", but it is trivial to make it configurable via
113+$GIT_DIR/config mechanism.
114+
115+With this workflow, you would want to know:
116+
117+(1) ... if a topic branch has ever been merged to "next".  Young
118+    topic branches can have stupid mistakes you would rather
119+    clean up before publishing, and things that have not been
120+    merged into other branches can be easily rebased without
121+    affecting other people.  But once it is published, you would
122+    not want to rewind it.
123+
124+(2) ... if a topic branch has been fully merged to "master".
125+    Then you can delete it.  More importantly, you should not
126+    build on top of it -- other people may already want to
127+    change things related to the topic as patches against your
128+    "master", so if you need further changes, it is better to
129+    fork the topic (perhaps with the same name) afresh from the
130+    tip of "master".
131+
132+Let's look at this example:
133+
134+		   o---o---o---o---o---o---o---o---o---o "next"
135+		  /       /           /           /
136+		 /   a---a---b A     /           /
137+		/   /               /           /
138+	       /   /   c---c---c---c B         /
139+	      /   /   /             \         /
140+	     /   /   /   b---b C     \       /
141+	    /   /   /   /             \     /
142+    ---o---o---o---o---o---o---o---o---o---o---o "master"
143+
144+
145+A, B and C are topic branches.
146+
147+ * A has one fix since it was merged up to "next".
148+
149+ * B has finished.  It has been fully merged up to "master" and "next",
150+   and is ready to be deleted.
151+
152+ * C has not merged to "next" at all.
153+
154+We would want to allow C to be rebased, refuse A, and encourage
155+B to be deleted.
156+
157+To compute (1):
158+
159+	git rev-list ^master ^topic next
160+	git rev-list ^master        next
161+
162+	if these match, topic has not merged in next at all.
163+
164+To compute (2):
165+
166+	git rev-list master..topic
167+
168+	if this is empty, it is fully merged to "master".
169+
170+DOC_END
A testdata/hooks/pre-receive.sample
+24, -0
 1@@ -0,0 +1,24 @@
 2+#!/bin/sh
 3+#
 4+# An example hook script to make use of push options.
 5+# The example simply echoes all push options that start with 'echoback='
 6+# and rejects all pushes when the "reject" push option is used.
 7+#
 8+# To enable this hook, rename this file to "pre-receive".
 9+
10+if test -n "$GIT_PUSH_OPTION_COUNT"
11+then
12+	i=0
13+	while test "$i" -lt "$GIT_PUSH_OPTION_COUNT"
14+	do
15+		eval "value=\$GIT_PUSH_OPTION_$i"
16+		case "$value" in
17+		echoback=*)
18+			echo "echo from the pre-receive-hook: ${value#*=}" >&2
19+			;;
20+		reject)
21+			exit 1
22+		esac
23+		i=$((i + 1))
24+	done
25+fi
A testdata/hooks/prepare-commit-msg.sample
+42, -0
 1@@ -0,0 +1,42 @@
 2+#!/bin/sh
 3+#
 4+# An example hook script to prepare the commit log message.
 5+# Called by "git commit" with the name of the file that has the
 6+# commit message, followed by the description of the commit
 7+# message's source.  The hook's purpose is to edit the commit
 8+# message file.  If the hook fails with a non-zero status,
 9+# the commit is aborted.
10+#
11+# To enable this hook, rename this file to "prepare-commit-msg".
12+
13+# This hook includes three examples. The first one removes the
14+# "# Please enter the commit message..." help message.
15+#
16+# The second includes the output of "git diff --name-status -r"
17+# into the message, just before the "git status" output.  It is
18+# commented because it doesn't cope with --amend or with squashed
19+# commits.
20+#
21+# The third example adds a Signed-off-by line to the message, that can
22+# still be edited.  This is rarely a good idea.
23+
24+COMMIT_MSG_FILE=$1
25+COMMIT_SOURCE=$2
26+SHA1=$3
27+
28+/usr/bin/perl -i.bak -ne 'print unless(m/^. Please enter the commit message/..m/^#$/)' "$COMMIT_MSG_FILE"
29+
30+# case "$COMMIT_SOURCE,$SHA1" in
31+#  ,|template,)
32+#    /usr/bin/perl -i.bak -pe '
33+#       print "\n" . `git diff --cached --name-status -r`
34+# 	 if /^#/ && $first++ == 0' "$COMMIT_MSG_FILE" ;;
35+#  *) ;;
36+# esac
37+
38+# SOB=$(git var GIT_COMMITTER_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
39+# git interpret-trailers --in-place --trailer "$SOB" "$COMMIT_MSG_FILE"
40+# if test -z "$COMMIT_SOURCE"
41+# then
42+#   /usr/bin/perl -i.bak -pe 'print "\n" if !$first_line++' "$COMMIT_MSG_FILE"
43+# fi
A testdata/hooks/push-to-checkout.sample
+78, -0
 1@@ -0,0 +1,78 @@
 2+#!/bin/sh
 3+
 4+# An example hook script to update a checked-out tree on a git push.
 5+#
 6+# This hook is invoked by git-receive-pack(1) when it reacts to git
 7+# push and updates reference(s) in its repository, and when the push
 8+# tries to update the branch that is currently checked out and the
 9+# receive.denyCurrentBranch configuration variable is set to
10+# updateInstead.
11+#
12+# By default, such a push is refused if the working tree and the index
13+# of the remote repository has any difference from the currently
14+# checked out commit; when both the working tree and the index match
15+# the current commit, they are updated to match the newly pushed tip
16+# of the branch. This hook is to be used to override the default
17+# behaviour; however the code below reimplements the default behaviour
18+# as a starting point for convenient modification.
19+#
20+# The hook receives the commit with which the tip of the current
21+# branch is going to be updated:
22+commit=$1
23+
24+# It can exit with a non-zero status to refuse the push (when it does
25+# so, it must not modify the index or the working tree).
26+die () {
27+	echo >&2 "$*"
28+	exit 1
29+}
30+
31+# Or it can make any necessary changes to the working tree and to the
32+# index to bring them to the desired state when the tip of the current
33+# branch is updated to the new commit, and exit with a zero status.
34+#
35+# For example, the hook can simply run git read-tree -u -m HEAD "$1"
36+# in order to emulate git fetch that is run in the reverse direction
37+# with git push, as the two-tree form of git read-tree -u -m is
38+# essentially the same as git switch or git checkout that switches
39+# branches while keeping the local changes in the working tree that do
40+# not interfere with the difference between the branches.
41+
42+# The below is a more-or-less exact translation to shell of the C code
43+# for the default behaviour for git's push-to-checkout hook defined in
44+# the push_to_deploy() function in builtin/receive-pack.c.
45+#
46+# Note that the hook will be executed from the repository directory,
47+# not from the working tree, so if you want to perform operations on
48+# the working tree, you will have to adapt your code accordingly, e.g.
49+# by adding "cd .." or using relative paths.
50+
51+if ! git update-index -q --ignore-submodules --refresh
52+then
53+	die "Up-to-date check failed"
54+fi
55+
56+if ! git diff-files --quiet --ignore-submodules --
57+then
58+	die "Working directory has unstaged changes"
59+fi
60+
61+# This is a rough translation of:
62+#
63+#   head_has_history() ? "HEAD" : EMPTY_TREE_SHA1_HEX
64+if git cat-file -e HEAD 2>/dev/null
65+then
66+	head=HEAD
67+else
68+	head=$(git hash-object -t tree --stdin </dev/null)
69+fi
70+
71+if ! git diff-index --quiet --cached --ignore-submodules $head --
72+then
73+	die "Working directory has staged changes"
74+fi
75+
76+if ! git read-tree -u -m "$commit"
77+then
78+	die "Could not update working tree to new HEAD"
79+fi
A testdata/hooks/sendemail-validate.sample
+77, -0
 1@@ -0,0 +1,77 @@
 2+#!/bin/sh
 3+
 4+# An example hook script to validate a patch (and/or patch series) before
 5+# sending it via email.
 6+#
 7+# The hook should exit with non-zero status after issuing an appropriate
 8+# message if it wants to prevent the email(s) from being sent.
 9+#
10+# To enable this hook, rename this file to "sendemail-validate".
11+#
12+# By default, it will only check that the patch(es) can be applied on top of
13+# the default upstream branch without conflicts in a secondary worktree. After
14+# validation (successful or not) of the last patch of a series, the worktree
15+# will be deleted.
16+#
17+# The following config variables can be set to change the default remote and
18+# remote ref that are used to apply the patches against:
19+#
20+#   sendemail.validateRemote (default: origin)
21+#   sendemail.validateRemoteRef (default: HEAD)
22+#
23+# Replace the TODO placeholders with appropriate checks according to your
24+# needs.
25+
26+validate_cover_letter () {
27+	file="$1"
28+	# TODO: Replace with appropriate checks (e.g. spell checking).
29+	true
30+}
31+
32+validate_patch () {
33+	file="$1"
34+	# Ensure that the patch applies without conflicts.
35+	git am -3 "$file" || return
36+	# TODO: Replace with appropriate checks for this patch
37+	# (e.g. checkpatch.pl).
38+	true
39+}
40+
41+validate_series () {
42+	# TODO: Replace with appropriate checks for the whole series
43+	# (e.g. quick build, coding style checks, etc.).
44+	true
45+}
46+
47+# main -------------------------------------------------------------------------
48+
49+if test "$GIT_SENDEMAIL_FILE_COUNTER" = 1
50+then
51+	remote=$(git config --default origin --get sendemail.validateRemote) &&
52+	ref=$(git config --default HEAD --get sendemail.validateRemoteRef) &&
53+	worktree=$(mktemp --tmpdir -d sendemail-validate.XXXXXXX) &&
54+	git worktree add -fd --checkout "$worktree" "refs/remotes/$remote/$ref" &&
55+	git config --replace-all sendemail.validateWorktree "$worktree"
56+else
57+	worktree=$(git config --get sendemail.validateWorktree)
58+fi || {
59+	echo "sendemail-validate: error: failed to prepare worktree" >&2
60+	exit 1
61+}
62+
63+unset GIT_DIR GIT_WORK_TREE
64+cd "$worktree" &&
65+
66+if grep -q "^diff --git " "$1"
67+then
68+	validate_patch "$1"
69+else
70+	validate_cover_letter "$1"
71+fi &&
72+
73+if test "$GIT_SENDEMAIL_FILE_COUNTER" = "$GIT_SENDEMAIL_FILE_TOTAL"
74+then
75+	git config --unset-all sendemail.validateWorktree &&
76+	trap 'git worktree remove -ff "$worktree"' EXIT &&
77+	validate_series
78+fi
A testdata/hooks/update.sample
+128, -0
  1@@ -0,0 +1,128 @@
  2+#!/bin/sh
  3+#
  4+# An example hook script to block unannotated tags from entering.
  5+# Called by "git receive-pack" with arguments: refname sha1-old sha1-new
  6+#
  7+# To enable this hook, rename this file to "update".
  8+#
  9+# Config
 10+# ------
 11+# hooks.allowunannotated
 12+#   This boolean sets whether unannotated tags will be allowed into the
 13+#   repository.  By default they won't be.
 14+# hooks.allowdeletetag
 15+#   This boolean sets whether deleting tags will be allowed in the
 16+#   repository.  By default they won't be.
 17+# hooks.allowmodifytag
 18+#   This boolean sets whether a tag may be modified after creation. By default
 19+#   it won't be.
 20+# hooks.allowdeletebranch
 21+#   This boolean sets whether deleting branches will be allowed in the
 22+#   repository.  By default they won't be.
 23+# hooks.denycreatebranch
 24+#   This boolean sets whether remotely creating branches will be denied
 25+#   in the repository.  By default this is allowed.
 26+#
 27+
 28+# --- Command line
 29+refname="$1"
 30+oldrev="$2"
 31+newrev="$3"
 32+
 33+# --- Safety check
 34+if [ -z "$GIT_DIR" ]; then
 35+	echo "Don't run this script from the command line." >&2
 36+	echo " (if you want, you could supply GIT_DIR then run" >&2
 37+	echo "  $0 <ref> <oldrev> <newrev>)" >&2
 38+	exit 1
 39+fi
 40+
 41+if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then
 42+	echo "usage: $0 <ref> <oldrev> <newrev>" >&2
 43+	exit 1
 44+fi
 45+
 46+# --- Config
 47+allowunannotated=$(git config --type=bool hooks.allowunannotated)
 48+allowdeletebranch=$(git config --type=bool hooks.allowdeletebranch)
 49+denycreatebranch=$(git config --type=bool hooks.denycreatebranch)
 50+allowdeletetag=$(git config --type=bool hooks.allowdeletetag)
 51+allowmodifytag=$(git config --type=bool hooks.allowmodifytag)
 52+
 53+# check for no description
 54+projectdesc=$(sed -e '1q' "$GIT_DIR/description")
 55+case "$projectdesc" in
 56+"Unnamed repository"* | "")
 57+	echo "*** Project description file hasn't been set" >&2
 58+	exit 1
 59+	;;
 60+esac
 61+
 62+# --- Check types
 63+# if $newrev is 0000...0000, it's a commit to delete a ref.
 64+zero=$(git hash-object --stdin </dev/null | tr '[0-9a-f]' '0')
 65+if [ "$newrev" = "$zero" ]; then
 66+	newrev_type=delete
 67+else
 68+	newrev_type=$(git cat-file -t $newrev)
 69+fi
 70+
 71+case "$refname","$newrev_type" in
 72+	refs/tags/*,commit)
 73+		# un-annotated tag
 74+		short_refname=${refname##refs/tags/}
 75+		if [ "$allowunannotated" != "true" ]; then
 76+			echo "*** The un-annotated tag, $short_refname, is not allowed in this repository" >&2
 77+			echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2
 78+			exit 1
 79+		fi
 80+		;;
 81+	refs/tags/*,delete)
 82+		# delete tag
 83+		if [ "$allowdeletetag" != "true" ]; then
 84+			echo "*** Deleting a tag is not allowed in this repository" >&2
 85+			exit 1
 86+		fi
 87+		;;
 88+	refs/tags/*,tag)
 89+		# annotated tag
 90+		if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1
 91+		then
 92+			echo "*** Tag '$refname' already exists." >&2
 93+			echo "*** Modifying a tag is not allowed in this repository." >&2
 94+			exit 1
 95+		fi
 96+		;;
 97+	refs/heads/*,commit)
 98+		# branch
 99+		if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then
100+			echo "*** Creating a branch is not allowed in this repository" >&2
101+			exit 1
102+		fi
103+		;;
104+	refs/heads/*,delete)
105+		# delete branch
106+		if [ "$allowdeletebranch" != "true" ]; then
107+			echo "*** Deleting a branch is not allowed in this repository" >&2
108+			exit 1
109+		fi
110+		;;
111+	refs/remotes/*,commit)
112+		# tracking branch
113+		;;
114+	refs/remotes/*,delete)
115+		# delete tracking branch
116+		if [ "$allowdeletebranch" != "true" ]; then
117+			echo "*** Deleting a tracking branch is not allowed in this repository" >&2
118+			exit 1
119+		fi
120+		;;
121+	*)
122+		# Anything else (is there anything else?)
123+		echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2
124+		exit 1
125+		;;
126+esac
127+
128+# --- Finished
129+exit 0
A testdata/info/exclude
+6, -0
1@@ -0,0 +1,6 @@
2+# git ls-files --others --exclude-from=.git/info/exclude
3+# Lines that start with '#' are comments.
4+# For a project mostly in C, the following would be a good set of
5+# exclude patterns (uncomment them if you want to use them):
6+# *.[oa]
7+# *~
A testdata/objects/83/e82623bc40b7a822f9ece0eb1a0df1f794e022
+0, -0
A testdata/objects/93/ec21917415ae74afec3fab3e734145b75019fc
+0, -0
A testdata/objects/f0/951d8b6d85667b3ec1b9df26a1647101b89bba
+0, -0
A testdata/refs/heads/main
+1, -0
1@@ -0,0 +1 @@
2+93ec21917415ae74afec3fab3e734145b75019fc