For a few years, I’ve generated my website — including a browsable Git repository site — using a large build script written in POSIX shell. After moving the main site generation to Quartz, I still needed a way to generate a Git repo site.

I figured — why not keep using POSIX shell scripting for that too? I also wanted the generation process to be automated and effortless, so I decided to use a post-receive hook in Git.

What I needed

  • A site generator for building a repo site for each Git repository
  • An index generator to link all the individual repo sites together
  • A generalized post-receive hook to trigger site generation on push
  • An upload script to publish everything externally

Here’s how it all fits together

Every time a push is made to a repository, the post-receive hook triggers the site generator for that specific repo and regenerates the central index. This ensures that the latest commits and changes are “immediately” reflected on the site. Then, at 02:00 every night, a scheduled upload script syncs the entire collection of repo sites — along with the updated index — to the external host.

The scripts

These are the scripts. Might need some polish — a quick hack!

View git2site
git2site
#!/bin/sh
 
set -e
 
if [ "$#" -ne 2 ]; then
    echo "Usage: $0 <repo.git> <output-path>"
    exit 1
fi
 
AUTHOR="Chris Noxz"
REPO_URL="https://git.noxz.tech"
FAVIOCON="/favicon.png"
 
# helper function to html encode stdin
# shellcheck disable=SC2120
html_encode() {
	if [ ! -t 0 ]; then
		cat -
	elif [ -n "$1" ]; then
		echo "$1"
	fi | sed \
		-e 's/&/\&amp;/g' \
		-e 's/"/\&quot;/g' \
		-e 's/</\&lt;/g' \
		-e 's/>/\&gt;/g'
}
 
# helper function to fix dot files if asked to do so
# shellcheck disable=SC2120
fix_dot_file() {
	if [ ! -t 0 ]; then
		cat -
	elif [ -n "${1}" ]; then
		echo "${1}"
	fi | sed \
		-e "s,/\.,/_\.,g" \
		-e "s,^\.,_\.,g"
}
 
REPO_PATH="${1%/}"
OUTPUT_PATH="${2%/}"
REPO_FILENAME="${REPO_PATH##*/}"
REPO_NAME="${REPO_FILENAME%.git}"
ARCHIVE_OUTPUT_PATH="${OUTPUT_PATH}/archive"
FILES_OUTPUT_PATH="${OUTPUT_PATH}/file"
COMMITS_OUTPUT_PATH="${OUTPUT_PATH}/commit"
LOG_PATH="${OUTPUT_PATH}/log.html"
FILES_PATH="${OUTPUT_PATH}/files.html"
TAGS_PATH="${OUTPUT_PATH}/tags.html"
 
if [ -z "${REPO_PATH}" ]; then
	echo "Error: <repo.git> is empty" >&2
	exit 1
fi
 
if [ -z "${OUTPUT_PATH}" ]; then
	echo "Error: <output-path> is empty" >&2
	exit 1
fi
 
if [ ! -d "${REPO_PATH}" ]; then
	echo "Error: directory '${REPO_PATH}' does not exist" >&2
	exit 1
fi
 
if [ ! -d "${OUTPUT_PATH}" ]; then
	echo "Error: directory '${OUTPUT_PATH}' does not exist" >&2
	exit 1
fi
 
if ! git -C "${REPO_PATH}" rev-parse --git-dir >/dev/null 2>&1; then
	echo "Directory '${REPO_PATH}' is not a Git repository"
	exit 1
fi
 
if [ "$(ls -A "${OUTPUT_PATH}")" ]; then
	echo "Directory '${OUTPUT_PATH}' is not empty"
	exit 1
fi
 
REPO_URL="${REPO_URL}/${REPO_FILENAME}"
REPO_DESCRIPTION="$([ -f "${REPO_PATH}/description" ] && html_encode < "${REPO_PATH}/description" || printf '')"
REPO_AUTHOR="$([ -f "${REPO_PATH}/owner" ] && html_encode < "${REPO_PATH}/owner" || printf '')"
REPO_ISFORK="$([ -f "${REPO_PATH}/fork" ] && printf '1' || printf '0')"
REPO_ISDISCONTINUED="$([ -f "${REPO_PATH}/discontinued" ] && printf '1' || printf '0')"
REPO_LAST_COMMIT="$(git --git-dir="${REPO_PATH}" log -1 --format='%ai' 2>/dev/null || echo "N/A")"
 
git clone --bare "${REPO_PATH}" "${OUTPUT_PATH}/repo"
git -C "${OUTPUT_PATH}/repo" --bare update-server-info
ln -s "${OUTPUT_PATH}/reporefs" "${OUTPUT_PATH}/repo/info/refs?service=git-upload-pack"
mv "${OUTPUT_PATH}/repo/"* "${OUTPUT_PATH}"
rmdir "${OUTPUT_PATH}/repo"
 
printf '%s\n%s\n%s\n%s\n%s\n%s\n%s' \
	"${REPO_NAME}" \
	"${REPO_DESCRIPTION}" \
	"${REPO_AUTHOR}" \
	"${REPO_URL}" \
	"${REPO_ISFORK}" \
	"${REPO_ISDISCONTINUED}" \
	"${REPO_LAST_COMMIT}" > "${OUTPUT_PATH}/.repo_metadata"
 
mkdir -p "${ARCHIVE_OUTPUT_PATH}"
mkdir -p "${FILES_OUTPUT_PATH}"
mkdir -p "${COMMITS_OUTPUT_PATH}"
 
REPO_MENU="$(
	printf '<a href="../log.html">Log</a>'
	printf ' | <a href="../files.html">Files</a>'
 
	if git -C "${REPO_PATH}" describe --tags >/dev/null 2>&1; then
		printf ' | <a href="../tags.html">Tags</a>'
	fi
 
	if git -C "${REPO_PATH}" show HEAD:README >/dev/null 2>&1; then
		printf ' | <a href="../file/README.html">README</a>'
	fi
 
	if git -C "${REPO_PATH}" show HEAD:LICENSE >/dev/null 2>&1; then
		printf ' | <a href="../file/LICENSE.html">LICENSE</a>'
	fi
)"
 
render_head() {
cat <<EOF
<!doctype html>
<html lang="en">
<head>
	<meta charset="utf-8">
	<meta name="author" content="${AUTHOR}">
	<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate"/>
	<meta http-equiv="Pragma" content="no-cache"/>
	<meta http-equiv="Expires" content="0"/>
	<title>Repositories: ${REPO_NAME}</title>
	<link rel="icon" type="image/png" href="${FAVIOCON}"/>
	<style>
	/* Base styles */
	body {
		color: #000;
		background-color: #fff;
		font-family: monospace;
	}
	
	h1, h2, h3, h4, h5, h6 {
		font-size: 1em;
		margin: 0;
	}
	
	img {
		border: 0;
		vertical-align: middle;
	}
	
	h1, h2 {
		vertical-align: middle;
	}
	
	a {
		color: #005386;
		text-decoration: none;
	}
	
	a:hover {
		text-decoration: underline;
	}
	
	/* Tables */
	table td {
		padding: 0 0.4em;
		white-space: nowrap;
		vertical-align: top;
	}
	
	table tr.nohi td {
		font-weight: bold;
	}
	
	table#files td:nth-child(3) {
		text-align: right;
	}
	
	table#files td:nth-child(3):after {
		content: " bytes";
	}
	
	table#files tr.nohi td:nth-child(3):after {
		content: "";
	}
	
	/* Highlight on hover */
	#index tr:hover td,
	#files tr:hover td,
	#log tr:hover td,
	#tags tr:hover td {
		background-color: #f3f1f9;
	}
	
	thead tr:hover td,
	tr.nohi:hover td {
		background-color: transparent !important;
	}
	
	/* Expand second column for specific tables */
	#index,
	#files,
	#log,
	#tags,
	#index td:nth-child(2),
	#files td:nth-child(2),
	#log td:nth-child(2),
	#tags td:nth-child(2) {
		width: 100%;
		white-space: normal;
	}
	
	/* Misc */
	.desc {
		color: #777;
	}
	
	hr {
		border: 0;
		border-top: 1px solid #777;
		height: 1px;
	}
	
	pre {
		font-family: monospace;
		tab-size: 4;
	}
	
	/* Code lines */
	pre a.line {
		display: inline-block;
		width: 32px;
		text-align: right;
		color: #777;
		padding-right: 5px;
		margin-right: 5px;
		border-right: 3px solid #eee;
		user-select: none;
	}
	
	pre a:target {
		background: #efefef;
	}
	
	/* Highlight types */
	pre a.h,
	pre a.i,
	pre a.d,
	table span.i,
	table span.d {
		text-decoration: none;
	}
	
	pre a.h {
		color: #00c;
	}
	
	pre a.h:target {
		background: #efefff;
	}
	
	pre a.i,
	table span.i {
		color: #080;
	}
	
	pre a.i:target {
		background: #efffef;
	}
	
	pre a.d,
	table span.d {
		color: #d00;
	}
	
	pre a.d:target {
		background: #ffefef;
	}
	
	pre b.r {
		color: #808;
		padding-left: 1em;
	}
	</style>
</head>
<body>
	<table>
		<tr>
			<td rowspan="3" valign="top"><a href="../.."><svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="32px" height="32px" viewBox="0 0 40 40" version="1.1" id="svg5995" inkscape:version="0.92.3 (2405546, 2018-03-11)" sodipodi:docname="logo.svg">
			<defs id="defs5989"/>
			<sodipodi:namedview id="base" pagecolor="#ffffff" bordercolor="#666666" borderopacity="1.0" inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:zoom="2" inkscape:cx="169.22484" inkscape:cy="86.16882" inkscape:document-units="mm" inkscape:current-layer="layer1" showgrid="false" inkscape:window-width="2560" inkscape:window-height="1024" inkscape:window-x="0" inkscape:window-y="0" inkscape:window-maximized="1"/>
			<metadata id="metadata5992">
				<rdf:RDF>
					<cc:Work rdf:about="">
						<dc:format>image/svg+xml</dc:format>
						<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
						<dc:title/>
					</cc:Work>
				</rdf:RDF>
			</metadata>
			<g inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1" transform="translate(0,-257)">
				<g id="g4971" transform="matrix(1.4417433,0,0,1.4417433,-185.22241,-98.665593)">
					<g id="g1447-3" transform="matrix(4.5481071,0,0,4.5481071,128.69855,246.91874)">
						<path id="path1435-6" style="fill:none;stroke:#000000;stroke-width:0.1;stroke-opacity:1" d="M 0,0 H 6 V 6 H 0 Z M 0,2 H 6 M 0,4 H 6 M 2,0 V 6 M 4,0 v 6" inkscape:connector-curvature="0"/>
					</g>
					<rect transform="rotate(-45)" y="275.88495" x="-79.740433" height="5.1449485" width="5.1449485" id="rect4878" style="opacity:1;fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.20321889;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke"/>
					<rect transform="rotate(-45)" y="288.75497" x="-79.733536" height="5.1449485" width="5.1449485" id="rect4878-2" style="opacity:1;fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.20321889;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke"/>
					<rect transform="rotate(-45)" y="295.18683" x="-86.165421" height="5.1449485" width="5.1449485" id="rect4878-2-9" style="opacity:1;fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.20321889;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke"/>
					<rect transform="rotate(-45)" y="288.75497" x="-92.597275" height="5.1449485" width="5.1449485" id="rect4878-2-9-2" style="opacity:1;fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.20321889;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke"/>
					<rect transform="rotate(-45)" y="282.31039" x="-99.041801" height="5.1449485" width="5.1449485" id="rect4878-2-9-2-7" style="opacity:1;fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.20321889;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke"/>
				</g>
			</g></svg></a></td>
			<td><h1>${REPO_NAME}</h1><span class="desc">${REPO_DESCRIPTION}</span></td>
		</tr>
		<tr>
			<td>git clone <a href="${REPO_URL}">${REPO_URL}</a></td>
		</tr>
		<tr>
			<td>${REPO_MENU}</td>
		</tr>
	</table>
	<hr />
EOF
}
 
render_tail() {
cat <<EOF
</body>
</html>
EOF
}
 
render_file() {
	printf '\t<span class="git-file">%s</span>\n\t<hr />\n' "${1}"
	if git -C "${REPO_PATH}" diff 4b825dc642cb6eb9a060e54bf8d69288fbee4904  \
		--numstat HEAD -- "${1}" | grep '^-' >/dev/null 2>&1; then
		printf '\t%s\n' "binary file."
	else
		git -C "${REPO_PATH}" cat-file "HEAD:${1}" -p                               \
		| html_encode                                                       \
		| awk '
			BEGIN {
				print "<pre>"
			}
 
			{
				print "<a class=\"line\" href=\"#l" NR "\" id=\"l" NR "\">" NR "</a>" $0
			}
 
			END {
				print "</pre>"
			}
		'
	fi
}
 
render_commit() {
	COMMIT_FILES="$(git -C "${REPO_PATH}" whatchanged "${1}"^! | grep '^:.*$' | cut -d' ' -f5 | sed 's/\t/:/g')"
 
	git -C "${REPO_PATH}" show --stat=1000 --stat-graph-width=20 "${1}"    \
	--format="format:%H%n%P%n%aN%n%aE%n%aD%n%B%H"                          \
	| sed 's/{\(.*\) => \(.*\)}/\1/g'                                      \
	| html_encode | awk -v flist="${COMMIT_FILES}" '
	BEGIN {
		marker=""; files=0; split(flist, farr, "\n");
		for (i in farr) {
			split(farr[i], ftmp, ":");
			farr[i,1] = substr(ftmp[1], 1, 1);
			farr[i,2] = ftmp[2];
			farr[i,3] = ftmp[3];
		}
	}
	{
		switch (NR) {
		case 1:
			marker=$0
			printf "<pre>\n"
			printf "<b>commit</b>: <a href=\"../commit/%s.html\">%s</a>\n",$0,$0
		break
		case 2:
			printf "<b>parent</b>: <a href=\"../commit/%s.html\">%s</a>\n",$0,$0
		break
		case 3:
			printf "<b>author</b>: %s",$0
		break
		case 4:
			if ($0 != "") { printf " &lt;<a href=\"mailto:%s\">%s</a>&gt;",$0,$0 }
			printf "\n"
		break
		case 5:
			printf "<b>date</b>:   %s\n",$0
			printf "</pre>\n"
		break
		case 6:
			printf "<pre class=\"body\">\n"
		default:
			if (files) {
				for (i in farr) {
					if ($1 == farr[i,2]) {
						printf "<tr>"
						printf "<td>%s</td><td><a href=\"#f%d\">%s</a>",farr[i,1],i,farr[i,2]
						if (farr[i,3] != "") {
							printf " &rarr; <a href=\"#f%d\">%s</a>",i,farr[i,3]
						}
						printf "</td>",$3
						printf "<td>%s</td>",$3
						printf "<td><span class=\"i\">"
						p=1
						for (j = 1; j <= length($4); j++) {
							if (p && substr($4, j, 1) != "+") {
								p=0; printf "</span><span class=\"d\">"
							}
							printf "%s",substr($4,j,1)
						}
						printf "</span></td>"
						print "</tr>"
						next
					}
				}
				sub(/^[ \t\r\n]+/, "", $0)
				printf "</table>\n%s\n",$0
			} else {
				if (marker != "" && marker == $0) { printf "</pre>\n<table>\n"; files=1 }
				else { print }
			}
		break
		}
	}'
	echo "<hr />"
	git -C "${REPO_PATH}" show "${1}" --format="" | html_encode | awk -v flist="${COMMIT_FILES}" '
	BEGIN {
		indiff=0; files=0; h=0; l=0 split(flist, farr, "\n");
		for (i in farr) {
			split(farr[i], ftmp, ":");
			farr[i,1] = ftmp[1];
			farr[i,2] = ftmp[2];
		}
		print "<pre>"
	}
	/^diff --git/ {
		indiff=1
		if (match($0, /^diff --git a\/(.*) b\/(.*)$/, m)) {
			for (i in farr) {
				if (m[1] == farr[i,2]) {
					printf "<b>diff --git"
					printf " a/<a id=\"f%d\" href=\"#f%d\">%s</a>",i,i,m[1]
					printf " b/<a href=\"#f%d\">%s</a>",i,m[2]
					printf "</b>\n"
					if (m[1] != m[2]) {
						printf "<b class=\"r\">rename %s &rarr; ",m[1]
						printf "%s</b>\n",m[2]
					}
					next
				}
			}
		}
		next
	}
	/^@@/ {
		indiff=0
		printf "<a href=\"#h%d\" id=\"h%d\" class=\"h\">%s</a>\n",h,h++,$0
		next
	}
	/^\+.*$/ {
		if (indiff) { next }
		printf "<a href=\"#l%d\" id=\"l%d\" class=\"i\">%s</a>\n",l,l++,$0
		next
	}
	/^\-.*$/ {
		if (indiff) { next }
		printf "<a href=\"#l%d\" id=\"l%d\" class=\"d\">%s</a>\n",l,l++,$0
		next
	}
	{
		if (indiff) { next }
		print
	}
	END {
		print "</pre>"
	}'
}
 
# Render tag archives
git -C "${REPO_PATH}" tag \
| xargs -I{} git -C "${REPO_PATH}" archive --format=tar.gz --prefix="${REPO_NAME}-{}/" -o "${ARCHIVE_OUTPUT_PATH}/${REPO_NAME}-{}.tar.gz" "{}"
 
# Render preview files
git -C "${REPO_PATH}" ls-tree -r --format="%(objectmode)%x0a%(path)" HEAD  \
| grep '^100[0-9][0-9][0-9]$' -A1 | grep -v '^--$\|^100[0-9][0-9][0-9]$' | while read -r file; do
	FILE_OUTPUT_PATH="$(echo "${FILES_OUTPUT_PATH}/${file}" | fix_dot_file).html"
	echo "[+] Generating file page: ${FILE_OUTPUT_PATH}"
 
	mkdir -p "${FILE_OUTPUT_PATH%/*}"
 
	FILE_DEPTH=$(printf "%s\n" "${file}" | awk -F/ '{ print NF }')
	PARENTS=$(printf '../%.0s' $(seq 1 $FILE_DEPTH))
 
	render_head | sed "s,href=\"../,href=\"${PARENTS},g" > "${FILE_OUTPUT_PATH}"
	render_file ${file} >> "${FILE_OUTPUT_PATH}"
	render_tail         >> "${FILE_OUTPUT_PATH}"
done
 
# Render commits
git -C "${REPO_PATH}" log --pretty="format:%H" | grep . | while read -r commit; do
	COMMIT_OUTPUT_PATH="${COMMITS_OUTPUT_PATH}/${commit}.html"
	echo "[+] Generating commit page: ${COMMIT_OUTPUT_PATH}"
 
	render_head              > "${COMMIT_OUTPUT_PATH}"
	render_commit ${commit} >> "${COMMIT_OUTPUT_PATH}"
	render_tail             >> "${COMMIT_OUTPUT_PATH}"
done
 
# Render log file
echo "[+] Generating log file"
render_head | sed 's,href="../,href="./,g' > "${LOG_PATH}"
git -C "${REPO_PATH}" log --pretty="format:%ai%n%s%n%an%n%H" \
| html_encode \
| awk '
	BEGIN {
		printf "\t<table id=\"log\">\n"
		printf "\t\t<tr class=\"nohi\">"
		printf "<td>Date</td>"
		printf "<td>Commit message</td>"
		printf "<td>Author</td>"
		printf "<td>Commit</td>"
		printf "</tr>\n"
		printf "\t\t<tbody>\n"
	}
 
	{
		entry[(NR-1) % 4] = $0
		if (NR % 4 == 0) {
			printf "\t\t\t<tr>"
			for (i = 0; i < 4; i++) {
				if (i == 1) {
					printf "<td><a href=\"commit/%s.html\">%s</a></td>",entry[3],entry[i]
				} else if (i == 3) {
					printf "<td><a href=\"commit/%s.html\">%*.*s</a></td>",entry[i],8,8,entry[i]
				} else {
					printf "<td>%s</td>",entry[i]
				}
			}
			printf "</tr>\n"
		}
	}
 
	END {
		printf "\t\t</tbody>\n"
		printf "\t</table>\n"
	}
' >> "${LOG_PATH}"
render_tail >> "${LOG_PATH}"
 
echo "[+] Generating files file"
# render files
render_head | sed 's,href="../,href="./,g' > "${FILES_PATH}"
git -C "${REPO_PATH}" ls-tree -r --format="%(objectmode)%x0a%(path)%x0a%(objectsize)" HEAD \
| html_encode \
| fix_dot_file \
| awk '
	function dotrep(data)
	{
		output = data
		gsub(/\/_\./, "/.", output)
		gsub(/^_\./, ".", output)
		return output
	}
 
	function oct2sym(data)
	{
		type = substr(data, 0, 3)
		if (type == "100") {
			type = "-"
		} else if (type == "060") {
			type = "b"
		} else if (type == "020") {
			type = "c"
		} else if (type == "040") {
			type = "d"
		} else if (type == "010") {
			type = "p"
		} else if (type == "120") {
			type = "l"
		} else if (type == "140") {
			type = "s"
		} else {
			type = "?"
		}
 
		sym = substr(data, 4, 3)
		gsub("0", "---", sym)
		gsub("1", "--x", sym)
		gsub("2", "-w-", sym)
		gsub("3", "-wx", sym)
		gsub("4", "r--", sym)
		gsub("5", "r-x", sym)
		gsub("6", "rw-", sym)
		gsub("7", "rwx", sym)
		return type sym
	}
 
	BEGIN {
		printf "\t<table id=\"files\">\n"
		printf "\t\t<tr class=\"nohi\">"
		printf "<td>Mode</td>"
		printf "<td>Name</td>"
		printf "<td>Size</td>"
		printf "</tr>\n"
		printf "\t\t<tbody>\n"
	}
 
	{
		entry[(NR-1) % 3] = $0
		if (NR % 3 == 0) {
			printf "\t\t\t<tr>"
			for (i = 0; i < 3; i++) {
				if (i == 1 && substr(entry[0], 0, 3) == "100") {
					printf "<td><a href=\"file/%s.html\">%s</a></td>", entry[i], dotrep(entry[i])
				} else {
					printf "<td>%s</td>", i == 0 ? oct2sym(entry[i]) : entry[i]
				}
			}
			printf "</tr>\n"
		}
	}
 
	END {
		printf "\t\t</tbody>\n"
		printf "\t</table>\n"
	}
' >> "${FILES_PATH}"
render_tail >> "${FILES_PATH}"
 
echo "[+] Generating tags file"
# render files
render_head | sed 's,href="../,href="./,g' > "${TAGS_PATH}"
# render tags
git -C "${REPO_PATH}" log --no-walk --tags --pretty="format:%S%n%ai%n%an%n%h" \
| html_encode \
| awk '
	BEGIN {
		printf "\t<table id=\"tags\">\n"
		printf "\t\t<tr class=\"nohi\">"
		printf "<td>Name</td>"
		printf "<td>Date</td>"
		printf "<td>Author</td>"
		printf "<td>Commit</td>"
		printf "</tr>\n"
		printf "\t\t<tbody>\n"
	}
 
	{
		entry[(NR-1) % 4] = $0
		if (NR % 4 == 0) {
			printf "\t\t\t<tr>"
			for (i = 0; i < 4; i++) {
				if (i == 0) {
					printf "<td><a href=\"./archive/__REPONAME__-%s.tar.gz\">%s</a></td>",entry[i],entry[i]
				} else {
					printf "<td>%s</td>",entry[i]
				}
			}
			printf "</tr>\n"
		}
	}
 
	END {
		printf "\t\t</tbody>\n"
		printf "\t</table>\n"
	}
' | sed "s/__REPONAME__/${REPO_NAME}/g" >> "${TAGS_PATH}"
render_tail >> "${TAGS_PATH}"
 
printf '<meta http-equiv="refresh" content="0; url=%s" />' "$(git -C "${REPO_PATH}" show HEAD:README >/dev/null 2>&1 && echo "${FILES_OUTPUT_PATH##*/}/README.html" || echo "${LOG_PATH##*/}")" > "${OUTPUT_PATH}/index.html"
View gengitindex
gengitindex
#!/bin/sh
 
AUTHOR="Chris Noxz"
HOME_URL="https://noxz.tech"
FAVIOCON="/favicon.png"
 
SITE_ROOT="/strg/src/stage/git-site"
INDEX_HTML="${SITE_ROOT}/index.html"
 
TMP_NORMAL=$(mktemp)
TMP_FORKS=$(mktemp)
TMP_DISCONTINUED=$(mktemp)
 
# Loop over all .repo_metadata files
find "$SITE_ROOT" -maxdepth 2 -name '.repo_metadata' | while IFS= read -r metafile; do
	REPO_DIR=$(dirname "$metafile")
	
	REPO_NAME=$(sed -n '1p' "$metafile")
	REPO_DESC=$(sed -n '2p' "$metafile")
	REPO_AUTHOR=$(sed -n '3p' "$metafile")
	REPO_URL=$(sed -n '4p' "$metafile")
	REPO_ISFORK=$(sed -n '5p' "$metafile")
	REPO_ISDISCONTINUED=$(sed -n '6p' "$metafile")
	REPO_LAST_COMMIT=$(sed -n '7p' "$metafile")
	
	GITDIR="${SITE_ROOT}/${REPO_NAME}.git"
	
	TR_LINE="<tr><td><a href=\"${REPO_URL##*/}\">${REPO_NAME}</a></td><td>${REPO_DESC}</td><td>${REPO_AUTHOR}</td><td>${REPO_LAST_COMMIT}</td></tr>"
	
	# Categorize into buckets
	if [ "$REPO_ISDISCONTINUED" = "1" ]; then
		echo "$TR_LINE" >> "$TMP_DISCONTINUED"
	elif [ "$REPO_ISFORK" = "1" ]; then
		echo "$TR_LINE" >> "$TMP_FORKS"
	else
		echo "$TR_LINE" >> "$TMP_NORMAL"
	fi
done
 
# Sort each group alphabetically by link text (between <a> and </a>)
sort_html_by_name() {
  sed 's/<tr><td><a href="[^"]*">//;s,</a>.*,,' "$1" | paste - "$1" | sort | cut -f2-
}
 
sort_html_by_name "$TMP_NORMAL" > "$TMP_NORMAL.sorted"
sort_html_by_name "$TMP_FORKS" > "$TMP_FORKS.sorted"
sort_html_by_name "$TMP_DISCONTINUED" > "$TMP_DISCONTINUED.sorted"
 
# Generate final HTML
{
cat <<EOF
<!doctype html>
<html lang="en">
<head>
	<meta charset="utf-8">
	<meta name="author" content="${AUTHOR}">
	<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate"/>
	<meta http-equiv="Pragma" content="no-cache"/>
	<meta http-equiv="Expires" content="0"/>
	<title>Repositories</title>
	<link rel="icon" type="image/png" href="${FAVIOCON}"/>
	<style>
	/* Base styles */
	body {
		color: #000;
		background-color: #fff;
		font-family: monospace;
	}
	
	h1, h2, h3, h4, h5, h6 {
		font-size: 1em;
		margin: 0;
	}
	
	img {
		border: 0;
		vertical-align: middle;
	}
	
	h1, h2 {
		vertical-align: middle;
	}
	
	a {
		color: #005386;
		text-decoration: none;
	}
	
	a:hover {
		text-decoration: underline;
	}
	
	/* Tables */
	table td {
		padding: 0 0.4em;
		white-space: nowrap;
		vertical-align: top;
	}
	
	table tr.nohi td {
		font-weight: bold;
	}
	
	/* Highlight on hover */
	#index tr:hover td,
	#files tr:hover td,
	#log tr:hover td,
	#tags tr:hover td {
		background-color: #f3f1f9;
	}
	
	thead tr:hover td,
	tr.nohi:hover td {
		background-color: transparent !important;
	}
	
	/* Expand second column for specific tables */
	#index,
	#files,
	#log,
	#tags,
	#index td:nth-child(2),
	#files td:nth-child(2),
	#log td:nth-child(2),
	#tags td:nth-child(2) {
		width: 100%;
		white-space: normal;
	}
	
	/* Misc */
	.desc {
		color: #777;
	}
	
	hr {
		border: 0;
		border-top: 1px solid #777;
		height: 1px;
	}
	
	pre {
		font-family: monospace;
		tab-size: 4;
	}
	
	/* Code lines */
	pre a.line {
		display: inline-block;
		width: 32px;
		text-align: right;
		color: #777;
		padding-right: 5px;
		margin-right: 5px;
		border-right: 3px solid #eee;
		user-select: none;
	}
	
	pre a:target {
		background: #efefef;
	}
	
	/* Highlight types */
	pre a.h,
	pre a.i,
	pre a.d,
	table span.i,
	table span.d {
		text-decoration: none;
	}
	
	pre a.h {
		color: #00c;
	}
	
	pre a.h:target {
		background: #efefff;
	}
	
	pre a.i,
	table span.i {
		color: #080;
	}
	
	pre a.i:target {
		background: #efffef;
	}
	
	pre a.d,
	table span.d {
		color: #d00;
	}
	
	pre a.d:target {
		background: #ffefef;
	}
	
	pre b.r {
		color: #808;
		padding-left: 1em;
	}
	</style>
</head>
<body>
	<table>
		<tr>
			<td rowspan="3" valign="top"><a href="../.."><svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="32px" height="32px" viewBox="0 0 40 40" version="1.1" id="svg5995" inkscape:version="0.92.3 (2405546, 2018-03-11)" sodipodi:docname="logo.svg">
			<defs id="defs5989"/>
			<sodipodi:namedview id="base" pagecolor="#ffffff" bordercolor="#666666" borderopacity="1.0" inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:zoom="2" inkscape:cx="169.22484" inkscape:cy="86.16882" inkscape:document-units="mm" inkscape:current-layer="layer1" showgrid="false" inkscape:window-width="2560" inkscape:window-height="1024" inkscape:window-x="0" inkscape:window-y="0" inkscape:window-maximized="1"/>
			<metadata id="metadata5992">
				<rdf:RDF>
					<cc:Work rdf:about="">
						<dc:format>image/svg+xml</dc:format>
						<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
						<dc:title/>
					</cc:Work>
				</rdf:RDF>
			</metadata>
			<g inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1" transform="translate(0,-257)">
				<g id="g4971" transform="matrix(1.4417433,0,0,1.4417433,-185.22241,-98.665593)">
					<g id="g1447-3" transform="matrix(4.5481071,0,0,4.5481071,128.69855,246.91874)">
						<path id="path1435-6" style="fill:none;stroke:#000000;stroke-width:0.1;stroke-opacity:1" d="M 0,0 H 6 V 6 H 0 Z M 0,2 H 6 M 0,4 H 6 M 2,0 V 6 M 4,0 v 6" inkscape:connector-curvature="0"/>
					</g>
					<rect transform="rotate(-45)" y="275.88495" x="-79.740433" height="5.1449485" width="5.1449485" id="rect4878" style="opacity:1;fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.20321889;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke"/>
					<rect transform="rotate(-45)" y="288.75497" x="-79.733536" height="5.1449485" width="5.1449485" id="rect4878-2" style="opacity:1;fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.20321889;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke"/>
					<rect transform="rotate(-45)" y="295.18683" x="-86.165421" height="5.1449485" width="5.1449485" id="rect4878-2-9" style="opacity:1;fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.20321889;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke"/>
					<rect transform="rotate(-45)" y="288.75497" x="-92.597275" height="5.1449485" width="5.1449485" id="rect4878-2-9-2" style="opacity:1;fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.20321889;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke"/>
					<rect transform="rotate(-45)" y="282.31039" x="-99.041801" height="5.1449485" width="5.1449485" id="rect4878-2-9-2-7" style="opacity:1;fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.20321889;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke"/>
				</g>
			</g></svg></a></td>
			<td><span class="desc">Repositories</span></td>
		</tr>
		<tr>
			<td><a href="${HOME_URL}">${HOME_URL##*://}</a></td>
		</tr>
	</table>
	<hr />
	<div id="content">
		<table id="index">
EOF
 
# Helper to emit each group
emit_group() {
  FILE="$1"
  if [ -s "$FILE" ]; then
    echo '      <tr class="nohi"><td>Name</td><td>Description</td><td>Owner</td><td>Last commit</td></tr>'
    echo '      <tbody'
    cat "$FILE"
    echo '      </tbody>'
    echo '      <tr class="nohi"><td colspan="4">&nbsp;</td></tr>'
  fi
}
 
emit_group "$TMP_NORMAL.sorted"
emit_group "$TMP_FORKS.sorted"
emit_group "$TMP_DISCONTINUED.sorted"
 
cat <<EOF
		</table>
	</div>
</body>
</html>
EOF
} > "$INDEX_HTML"
 
# Clean up
rm -f "$TMP_NORMAL" "$TMP_FORKS" "$TMP_DISCONTINUED"
rm -f "$TMP_NORMAL.sorted" "$TMP_FORKS.sorted" "$TMP_DISCONTINUED.sorted"
View post-receive
post-receive
#!/bin/bash
 
export PATH=/usr/bin:/bin:/usr/local/bin
set -euo pipefail
 
# Get path of the current (bare) Git repo — this is where the hook is running
GIT_REPO_PATH=$(dirname "$(dirname "$0")")
REPO_NAME="$(basename "$GIT_REPO_PATH" .git)"
 
# Define where the static site should be generated
GIT_SITE_PATH="/strg/src/stage/git-site"
REPO_SITE_PATH="${GIT_SITE_PATH}/${REPO_NAME}.git"
 
echo "[POST RECEIVE HOOK] Generating site for repository: $REPO_NAME"
echo "Repo path: $GIT_REPO_PATH"
echo "Target site path: $REPO_SITE_PATH"
 
# Ensure target directory exists
mkdir -p "${REPO_SITE_PATH}"
 
# Sanity check before removing contents
if [[ -z "$REPO_SITE_PATH" || "$REPO_SITE_PATH" == "/" || ! -d "$REPO_SITE_PATH" ]]; then
	echo "Error: Invalid or dangerous REPO_SITE_PATH: '$REPO_SITE_PATH'" >&2
	exit 1
fi
 
# Clean old site content safely
rm -rf "${REPO_SITE_PATH}/"*
rm -f "${REPO_SITE_PATH}/.repo_metadata"
 
# Generate site from Git repo
git2site "$GIT_REPO_PATH" "$REPO_SITE_PATH"
gengitindex
 
echo "[POST RECEIVE HOOK] Site generation completed."

As css style is included in each .html file there is no need for a separate style.css file. Except for these scripts, favicon.png and favicon.ico are needed to be placed inside the git-site directory.

Updating the style is as simple as editing the script and trigger site generation for every repo:

find /strg/src/z0noxz/ -type f -path '*/hooks/post-receive' | xargs -I{} sh -c 'echo "0000000000000000000000000000000000000000 eabf1234567890abcdef1234567890abcdef1234 refs/heads/main" | {}'