/git3html
1 #!/bin/sh
2
3 # Copyright 2019 Vasilii Kolobkov
4
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation, either version 3 of the License, or
8 # (at your option) any later version.
9
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
14
15 # You should have received a copy of the GNU General Public License
16 # along with this program. If not, see <https://www.gnu.org/licenses/>.
17
18 set -eu
19 ht="$(printf '\t')"
20
21 # ........ functions ........
22
23 escape() {
24 # assume the input is in utf8
25 # \302\240 is U+00A0 or in utf8
26 sed "s,&,\&,g; s,$(printf '\302\240'),\ ,g; \
27 s,\",\",g; s,<,\<,g; s,>,\>,g"
28 }
29
30 parent() {
31 if [ "${1%/}" = '.' ]; then
32 echo ".."
33 else
34 echo "${1%/}/.."
35 fi
36 }
37
38 # snatched from libgit2
39 isbinary() {
40 od -A n -N 256 -t u1 | awk '
41 {
42 for(i=1; i <= NF; ++i) {
43 c=$i
44 if ((c > 31 && c != 127) || c == 8 || c == 27 || c == 12) {
45 # BS, ESC, FF and anything over US excluding DEL is printable
46 ++printable
47 } else if (c == 0) {
48 null=1
49 exit
50 } else if (c < 9 || c > 13) {
51 # everything else, sans [:space:] is not
52 ++nonprintable
53 }
54 }
55 }
56
57 END {
58 if (null) print "y"
59 else print (printable / 128 < nonprintable) ? "y" : "n"
60 }'
61 }
62
63 g() {
64 git -C "$repodir" "$@"
65 }
66
67 # tree of the most recent commit
68 tmrc() {
69 g rev-list -n 1 --all --format=tformat:%T | tail -n +2
70 }
71
72 # this and morecommits should use same filter options to maintain the
73 # invariant: morecommits exits with positive status if pickcommits selects
74 # proper subset of all commits
75 pickcommits() {
76 g rev-list ${clim+-n $clim} --all --date-order "$@"
77 }
78
79 morecommits() {
80 [ -n "${clim:-}" ] &&
81 [ "$(g rev-list --skip="$clim" -n 1 --all | wc -l | cut -d ' ' -f1)" -gt 0 ]
82 }
83
84 defaultname() {
85 if [ "$(basename "$repodir")" = '.' ]; then
86 basename $(pwd)
87 else
88 basename "$repodir"
89 fi | sed 's/\.git$//'
90 }
91
92 header() (
93 title="$1"
94 toroot="$2"
95
96 cat <<-END
97 <!doctype html>
98 <html>
99 <head>
100 END
101 printf '<title>%s</title>\n' "$(echo "$title" | escape)"
102 printf '<link rel="stylesheet" type="text/css" href="%s">\n' \
103 "$(echo "${toroot%/}/style.css" | escape)"
104 cat <<-END
105 </head>
106 <body>
107 <header>
108 END
109 printf '<h1>%s</h1>\n' "$(echo "$projname" | escape)"
110 if [ -n "$cloneurl" ]; then
111 printf '<pre><code>git clone %s</code></pre>\n' \
112 "$(echo "$cloneurl" | escape)"
113 fi
114 cat <<-END
115 <nav>
116 <ul>
117 END
118 printf '<li><a href="%s">Readme</a></li>\n' \
119 "$(echo "${toroot%/}/readme.html" | escape)"
120 printf '<li><a href="%s">Log</a></li>\n' \
121 "$(echo "${toroot%/}/log.html" | escape)"
122 printf '<li><a href="%s">Files</a></li>\n' \
123 "$(echo "${toroot%/}/files.html" | escape)"
124 cat <<-END
125 </ul>
126 </nav>
127 </header>
128 END
129 )
130
131 footer() {
132 cat <<-END
133 </body>
134 </html>
135 END
136 }
137
138 readme() (
139 header "${projname} readme" .
140 root=$(tmrc)
141 printf '<h2>Readme</h2>\n'
142 printf '<pre>'
143 g ls-tree --name-only "$root" | grep '^README*' | while read -r readme; do
144 g cat-file --textconv "$root:$readme" | escape
145 done
146 printf '</pre>'
147 footer
148 )
149
150 commit() (
151 id="$1"
152 g rev-list -n 1 --date=format:%x --format='%h%n%an%n%ae%n%ad%n%cn%n%ce%n%cd%n%s%n%b' "$id" | \
153 tail -n +2 | escape | {
154 IFS= read -r shortid
155 header "${projname} commit ${shortid}" ..
156 printf '<h2>%s<span class="id-suffix">%s</h2>\n' "$shortid" \
157 "$(echo "$id" | cut -c "$(expr ${#shortid} + 1)-")"
158 for v in an ae ad cn ce cd subj; do
159 IFS= read -r "$v"
160 done
161 printf '<p>Author: <a href="mailto:%s">%s</a> on %s</p>\n' "$ae" "$an" "$ad"
162 printf '<p>Committer: <a href="mailto:%s">%s</a> on %s</p>\n' "$ce" "$cn" "$cd"
163 printf '<p>%s</p>\n' "$subj"
164 body="$(cat)"
165 if [ "${#body}" -gt 0 ]; then
166 printf '<p>'
167 bodylnfmt='%s'
168 echo "$body" | while IFS= read -r bodyln; do
169 printf "$bodylnfmt" "$bodyln"
170 bodylnfmt='<br>\n%s'
171 done
172 printf '</p>\n'
173 fi
174 }
175
176 printf '<h3>Stats</h3>\n'
177 printf '<pre><code>'
178 g diff-tree --stat --stat-graph-width=8 --root "$id" | tail -n +2 | escape | sed \
179 '$d;
180 s#\(+*\)\(-*\)$#<span class="add">\1</span><span class="delete">\2</span>#
181 s/^[[:space:]]*//;'
182 g diff-tree --stat --stat-graph-width=8 --root "$id" | tail -n 1 | escape | sed \
183 's,+,<span class="add">+</span>,;
184 s,-,<span class="delete">-</span>,;
185 s/^[[:space:]]*//;'
186 printf '</code></pre>\n'
187 printf '<h3>Patch</h3>\n'
188 printf '<pre><code>'
189 g diff-tree -p --root "$id" | tail -n +2 | escape | sed "\
190 $(for p in diff old new deleted copy rename similarity dissimilarity index; do
191 echo "/^$p/ { s,^,<span class=\"diff-header\">,; s,$,</span>,; }"
192 done;)
193 /^@@/ { s,^,<span class=\"hunk-header\">,; s,$,</span>,; }
194 /^+/ { s,^,<span class=\"add\">,; s,$,</span>,; }
195 /^-/ { s,^,<span class=\"delete\">,; s,$,</span>,; }"
196 printf '</code></pre>\n'
197 footer
198 )
199
200 log() (
201 outdir="$1"
202 cdir="${outdir%/}/commits"
203 if [ ! -d "$cdir" ]; then
204 mkdir "$cdir"
205 fi
206 exec >"${outdir%/}/log.html"
207
208 header "${projname} log" .
209 cat <<-END
210 <h2>Log</h2>
211 <table>
212 <thead>
213 <tr>
214 <th>Subject</th>
215 <th>Date</th>
216 </tr>
217 </thead>
218 <tbody>
219 END
220 pickcommits --date=format:%x --format=tformat:%s%n%cd | while read prefix id; do
221 commit "$id" >"${cdir%/}/${id}.html"
222 IFS= read -r subj
223 IFS= read -r date
224 printf '<tr>\n<td><a href="commits/%s.html">%s</a></td>\n<td>%s</td>\n</tr>\n' \
225 "$id" "$(echo "$subj" | escape)" "$(echo "$date" | escape)"
226 done
227 if morecommits; then
228 cat <<-END
229 <tr>
230 <td>...</td>
231 </tr>
232 END
233 fi
234 cat <<-END
235 </tbody>
236 </table>
237 END
238 footer
239 )
240
241 dirent() {
242 printf '<li><span class="sigil">d</span> <a href="%s">%s</a></li>\n' \
243 "$(echo "$2" | escape)" "$(echo "$1" | escape)"
244 }
245
246 blobent() (
247 mode="$1"
248 blob="$2"
249 name="$3"
250 outdir="$4"
251 relout="$5" # outdir relative to index dir
252 reppath="$6"
253 relbaseoutdir="$7"
254
255 type="$(expr \( $mode - $mode % 10000 \) / 10000)"
256 if [ "$type" -eq 10 ]; then # regular file
257 if [ "$(g cat-file -p "$blob" | isbinary)" = y ]; then
258 printf '<li><span class="sigil">b</span> %s</li>\n' \
259 "$(echo "$name" | escape)"
260 else
261 textfile "$blob" "$reppath" "$relbaseoutdir" > "${outdir%/}/${name}.html"
262 printf '<li><span class="sigil">t</span> <a href="%s">%s</a></li>\n' \
263 "$(echo "${relout%/}/${name}.html" | escape)" \
264 "$(echo "$name" | escape)"
265 fi
266 elif [ "$type" -eq 12 ]; then # link
267 printf "<li><span class="sigil">l</span> %s</li>\n" "$(echo "$name" | escape)"
268 else
269 printf "<li><span class="sigil">o</span> %s</li>\n" "$(echo "$name" | escape)"
270 fi
271 )
272
273 textfile() (
274 blob="$1"
275 reppath="$2"
276 relbaseoutdir="$3"
277
278 header "${projname} ${reppath}" "$relbaseoutdir"
279 printf '<h2>%s</h2>\n' "$(echo "$reppath" | escape)"
280 printf '<pre><code>'
281 n=0
282 g cat-file blob "$blob" | expand | escape | awk -F '' '{
283 ++n
284 printf "<a href=\"#l%d\" class=\"linea\">%4d</a> %s\n", n, n, $0
285 }'
286 printf '</code></pre>\n'
287 footer
288 )
289
290 files() (
291 tree="$1"
292 name="$2"
293 outdir="$3"
294 relbaseoutdir="$4" # these two are
295 relparidx="$5" # relative to outdir
296 reppath="$6"
297
298 {
299 header "${projname} ${reppath}" "$relbaseoutdir"
300 printf '<h2>%s</h2>\n' "$(echo "$reppath" | escape)"
301 echo '<ul class="dir">'
302 if [ -n "$relparidx" ]; then
303 dirent '..' "$relparidx"
304 fi
305 g ls-tree "$tree" | awk '$2 == "tree"' | while read rec; do
306 subtree="$(echo "$rec" | awk '{ print $3 }')"
307 stname="$(echo "$rec" | cut -d "$ht" -f2)"
308 stoutdir="${outdir%/}/${name}"
309 if [ ! -d "$stoutdir" ]; then
310 mkdir "$stoutdir"
311 fi
312
313 files "$subtree" "$stname" "${stoutdir}" "$(parent "${relbaseoutdir}")" \
314 "../${name}.html" "${reppath%/}/${stname}"
315
316 dirent "${stname}" "${name}/${stname}.html"
317 done
318 g ls-tree "$tree" | awk '$2 == "blob"' | while read rec; do
319 bmode="$(echo "$rec" | awk '{ print $1 }')"
320 blob="$(echo "$rec" | awk '{ print $3 }')"
321 bname="$(echo "$rec" | cut -d "$ht" -f2)"
322 boutdir="${outdir%/}/${name}"
323 if [ ! -d "$boutdir" ]; then
324 mkdir "$boutdir"
325 fi
326 blobent "$bmode" "$blob" "$bname" "$boutdir" "$name" \
327 "${reppath%/}/${bname}" "$(parent "$relbaseoutdir")"
328 done
329 echo '</ul>'
330 footer
331 } > "${outdir%/}/${name}.html"
332 )
333
334 # ........ driver ........
335
336 usage() {
337 echo "Usage: $0 [-p name] [-c url] [-n lim] repodir htmldir" >&2
338 exit 1
339 }
340
341 while getopts 'p:c:c:h' opt; do
342 case "$opt" in
343 p)
344 projname="$OPTARG"
345 ;;
346 c)
347 cloneurl="$OPTARG"
348 ;;
349 n)
350 clim="$OPTARG"
351 ;;
352 *)
353 usage
354 ;;
355 esac
356 done
357
358 shift $(expr "$OPTIND" - 1)
359 if [ $# -ne 2 ]; then
360 usage
361 fi
362
363 repodir="$1"
364 htmldir="$2"
365 projname="${projname:-$(defaultname)}"
366
367 if [ ! -d "$htmldir" ]; then
368 mkdir -p "$htmldir"
369 fi
370
371 readme > "${htmldir%/}/readme.html"
372 log "$htmldir"
373 files "$(tmrc)" 'files' "$htmldir" '.' '' '/'