+6,
-0
1@@ -0,0 +1,6 @@
2+pgit
3+*.swp
4+*.log
5+public/
6+testdata.site/
7+testdata.repo/
+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
+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
+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)
+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+}
+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=
+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}}
+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,
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}}
+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> · </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 }}
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}}
+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}}
+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> · </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}}
+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}}
+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}}
+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+}
+1,
-0
1@@ -0,0 +1 @@
2+# dont ignore any files
+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+}
+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+}
+1,
-0
1@@ -0,0 +1 @@
2+ref: refs/heads/main
+4,
-0
1@@ -0,0 +1,4 @@
2+[core]
3+ repositoryformatversion = 0
4+ filemode = true
5+ bare = true
+1,
-0
1@@ -0,0 +1 @@
2+Unnamed repository; edit this file 'description' to name the repository.
+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+:
+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+}
+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+}
+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
+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+:
+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 --
+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+:
+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
+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
+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
+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
+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
+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
+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
+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+# *~
+1,
-0
1@@ -0,0 +1 @@
2+93ec21917415ae74afec3fab3e734145b75019fc