greenhouse

greenhouse

forge suite

git clone [email protected]:greenhouse.git

feat(*): init

commit3d35447859ea3605eed5b6300b61736fce6eeedd
authorlostduk <[email protected]>
date2026-10-14 11:28 +0000
.gitignore4+
petal/Makefile34++
petal/config.def.h14+
petal/config.mk13+
petal/petal.c1170++++++++++++++++++++++++++++++++++++++++
petal/style.css335++++++++++++

6 files changed, -0/+1570

.gitignore

@@ -0,0 +1,4 @@+.dev/+petal/petal+petal/*.o+petal/config.h

petal/Makefile

@@ -0,0 +1,34 @@+include config.mk++SRC = petal.c+OBJ = ${SRC:.c=.o}++all: petal++.c.o:+	${CC} -c ${CFLAGS} $<++${OBJ}: config.h config.mk++config.h:+	cp config.def.h $@++petal: ${OBJ}+	${CC} -o $@ ${OBJ} ${LDFLAGS}++clean:+	rm -f petal ${OBJ}++install: all+	mkdir -p ${DESTDIR}${PREFIX}/bin+	cp -f petal ${DESTDIR}${PREFIX}/bin+	chmod 755 ${DESTDIR}${PREFIX}/bin/petal+	mkdir -p ${DESTDIR}${PREFIX}/share/petal+	cp -f style.css ${DESTDIR}${PREFIX}/share/petal+	chmod 644 ${DESTDIR}${PREFIX}/share/petal/style.css++uninstall:+	rm -f ${DESTDIR}${PREFIX}/bin/petal\+		${DESTDIR}${PREFIX}/share/petal/style.css++.PHONY: all clean install uninstall

petal/config.def.h

@@ -0,0 +1,14 @@+static const char *root    = "/srv/git";+static const char *css     = "/style.css";+static const char *clone   = "[email protected]:";+static const char *title   = "greenhouse";+static const char *mark    = "❀";+static const char *service = "git";+static const int loglimit  = 100;+static const int maxdepth  = 8;+static const int barwidth  = 40;++static const Service services[] = {+	{ "git",   "https://git.lostduk.com/" },+	{ "lists", "https://lists.lostduk.com/" },+};

petal/config.mk

@@ -0,0 +1,13 @@+PREFIX = /usr/local++GIT2INC = /usr/local/include+GIT2LIB = /usr/local/lib++INCS = -I${GIT2INC}+LIBS = -L${GIT2LIB} -lgit2++CPPFLAGS = -D_XOPEN_SOURCE=700+CFLAGS   = -std=c99 -pedantic -Wall -Wextra -Os ${INCS} ${CPPFLAGS}+LDFLAGS  = ${LIBS}++CC = cc

petal/petal.c

@@ -0,0 +1,1170 @@+/* imports */+#include <ctype.h>+#include <dirent.h>+#include <limits.h>+#include <stdarg.h>+#include <stdio.h>+#include <stdlib.h>+#include <string.h>+#include <time.h>+#include <sys/stat.h>+#include <git2.h>++/* macros */+#define LENGTH(X) (sizeof(X) / sizeof((X)[0]))+#define HTML      "text/html; charset=utf-8"+#define HOUR      3600L+#define DAY       (24 * HOUR)++/* enums */+typedef struct {+	const char *name;+	const char *url;+} Service;++/* function declarations */+static int bprintf(char *buf, size_t len, const char *fmt, ...);+static int commitdiff(git_diff **diff, git_commit *commit);+static void commitpage(const char *id);+static const char *envor(const char *name, const char *fallback);+static void filepage(const char *path, const git_tree_entry *e);+static int findref(char *spec, char **rest);+static int headcommit(git_commit **commit, git_repository *r);+static void httpheader(int status, const char *type);+static void indexpage(void);+static void indexrow(const char *name);+static int isrepo(const char *name);+static int loadref(git_reference *r);+static void logpage(void);+static void logrow(const git_oid *oid);+static const char *modestr(git_filemode_t mode);+static void notfound(void);+static int openrepo(git_repository **r, const char *name);+static void pagebegin(int status, const char *pagetitle);+static void pageend(void);+static void printage(const git_time *t);+static void printdiff(git_diff *diff);+static void printname(const char *name);+static void printpath(const char *path);+static void printstat(git_diff *diff);+static void printtime(const git_time *t, const char *fmt);+static void printurl(const char *page, const char *r, const char *path);+static void rawpage(const char *path);+static void readdesc(const char *name, char *buf, size_t len);+static void readfile(const char *name, const char *file, char *buf,+                     size_t len);+static void refrows(const char *glob);+static void refspage(void);+static void repoheader(const char *tab);+static void route(char *path);+static void treepage(const char *path);+static void treerow(const git_tree_entry *e, const char *path);+static void treetable(const git_tree *tree, const char *path);+static void urlencode(const char *s);+static int validname(const char *s);+static void walk(const char *rel, int depth);+static void walkent(const char *dir, const char *rel, const char *e,+                    int depth);+static void xmlencode(const char *s);+static void xmlencoden(const char *s, size_t len);++/* variables */+static const char refshead[] =+	"<thead><tr><th>Name</th><th>Last commit</th>"+	"<th class=\"sm\">Author</th></tr></thead>\n<tbody>\n";+static const char *base;+static const char *reponame;+static git_repository *repo;+static char ref[PATH_MAX];+static git_oid tip;+static long ofs;++#include "config.h"++/* function implementations */+int+bprintf(char *buf, size_t len, const char *fmt, ...)+{+	va_list ap;+	int n;++	va_start(ap, fmt);+	n = vsnprintf(buf, len, fmt, ap);+	va_end(ap);+	if (n < 0 || (size_t)n >= len)+		return -1;+	return 0;+}++int+commitdiff(git_diff **diff, git_commit *commit)+{+	git_commit *parent = NULL;+	git_tree *tree = NULL, *ptree = NULL;+	git_diff_options opts;+	git_diff_find_options fopts;+	int err;++	*diff = NULL;+	git_diff_options_init(&opts, GIT_DIFF_OPTIONS_VERSION);+	git_diff_find_options_init(&fopts, GIT_DIFF_FIND_OPTIONS_VERSION);+	opts.flags |= GIT_DIFF_DISABLE_PATHSPEC_MATCH;+	opts.flags |= GIT_DIFF_IGNORE_SUBMODULES;+	fopts.flags |= GIT_DIFF_FIND_RENAMES;++	if ((err = git_commit_tree(&tree, commit)) < 0)+		goto end;+	if (git_commit_parentcount(commit)) {+		if ((err = git_commit_parent(&parent, commit, 0)) < 0)+			goto end;+		if ((err = git_commit_tree(&ptree, parent)) < 0)+			goto end;+	}+	if ((err = git_diff_tree_to_tree(diff, repo, ptree, tree, &opts)) < 0)+		goto end;+	err = git_diff_find_similar(*diff, &fopts);+end:+	if (err < 0) {+		git_diff_free(*diff);+		*diff = NULL;+	}+	git_tree_free(ptree);+	git_tree_free(tree);+	git_commit_free(parent);+	return err;+}++void+commitpage(const char *id)+{+	char spec[128];+	git_object *obj;+	git_commit *c;+	git_diff *diff;+	const git_signature *a;+	const char *body, *pid;+	unsigned int i;++	if (bprintf(spec, sizeof(spec), "%s^{commit}", id) < 0+	|| git_revparse_single(&obj, repo, spec) < 0) {+		notfound();+		return;+	}+	c = (git_commit *)obj;+	a = git_commit_author(c);++	pagebegin(200, git_commit_summary(c));+	repoheader("log");+	fputs("<h2 class=\"subject\">", stdout);+	xmlencode(git_commit_summary(c));+	fputs("</h2>\n", stdout);+	if ((body = git_commit_body(c))) {+		fputs("<pre class=\"body\">", stdout);+		xmlencode(body);+		fputs("</pre>\n", stdout);+	}+	printf("<table class=\"meta\">\n<tr><td>commit</td><td>%s</td></tr>\n",+		git_oid_tostr_s(git_commit_id(c)));+	for (i = 0; i < git_commit_parentcount(c); i++) {+		pid = git_oid_tostr_s(git_commit_parent_id(c, i));+		fputs("<tr><td>parent</td><td><a href=\"", stdout);+		printurl("commit", pid, NULL);+		printf("\">%s</a></td></tr>\n", pid);+	}+	fputs("<tr><td>author</td><td>", stdout);+	xmlencode(a->name);+	fputs(" &lt;", stdout);+	xmlencode(a->email);+	fputs("&gt;</td></tr>\n<tr><td>date</td><td>", stdout);+	printtime(&a->when, "%Y-%m-%d %H:%M");+	printf(" %c%02d%02d</td></tr>\n</table>\n", a->when.sign,+		abs(a->when.offset) / 60, abs(a->when.offset) % 60);+	if (commitdiff(&diff, c) >= 0) {+		printstat(diff);+		printdiff(diff);+	}+	pageend();+	git_diff_free(diff);+	git_commit_free(c);+}++const char *+envor(const char *name, const char *fallback)+{+	const char *v;++	v = getenv(name);+	return v && *v ? v : fallback;+}++void+filepage(const char *path, const git_tree_entry *e)+{+	git_blob *blob;+	const char *s, *nl;+	size_t len, i, l, n, lines;+	int binary;++	if (git_blob_lookup(&blob, repo, git_tree_entry_id(e)) < 0) {+		notfound();+		return;+	}+	s = git_blob_rawcontent(blob);+	len = (size_t)git_blob_rawsize(blob);+	binary = git_blob_is_binary(blob);+	for (i = 0, lines = 0; i < len; i++)+		if (s[i] == '\n')+			lines++;+	if (len && s[len - 1] != '\n')+		lines++;++	pagebegin(200, reponame);+	repoheader("tree");+	printpath(path);+	fputs("<span class=\"info\">", stdout);+	if (!binary)+		printf("%zu lines &middot; ", lines);+	printf("%zuB &middot; <a href=\"", len);+	printurl("raw", ref, path);+	fputs("\">raw</a></span></p>\n", stdout);+	if (binary) {+		fputs("<p>Binary file.</p>\n", stdout);+	} else {+		fputs("<pre class=\"blob\">", stdout);+		for (i = 0, n = 1; i < len; i += l + 1, n++) {+			nl = memchr(s + i, '\n', len - i);+			l = nl ? (size_t)(nl - s) - i : len - i;+			printf("<a id=\"l%zu\" href=\"#l%zu\">%4zu</a> ",+				n, n, n);+			xmlencoden(s + i, l);+			putchar('\n');+		}+		fputs("</pre>\n", stdout);+	}+	pageend();+	git_blob_free(blob);+}++int+findref(char *spec, char **rest)+{+	git_reference *r;+	char *cut = NULL, *p;++	for (;;) {+		if (git_reference_dwim(&r, repo, spec) >= 0+		&& loadref(r) >= 0) {+			*rest = cut ? cut + 1 : spec + strlen(spec);+			return 0;+		}+		if (cut)+			*cut = '/';+		p = cut ? cut : spec + strlen(spec);+		while (p > spec && *--p != '/')+			;+		if (p == spec)+			return -1;+		*p = '\0';+		cut = p;+	}+}++int+headcommit(git_commit **commit, git_repository *r)+{+	git_object *obj;++	if (git_revparse_single(&obj, r, "HEAD^{commit}") < 0)+		return -1;+	*commit = (git_commit *)obj;+	return 0;+}++void+httpheader(int status, const char *type)+{+	if (status == 404)+		fputs("Status: 404 Not Found\r\n", stdout);+	printf("Content-Type: %s\r\n", type);+	fputs("X-Content-Type-Options: nosniff\r\n\r\n", stdout);+}++void+indexpage(void)+{+	pagebegin(200, title);+	fputs("<main>\n<table class=\"repos\">\n<thead><tr><th>Name</th>"+		"<th class=\"sm\">Description</th><th class=\"sm\">Owner</th>"+		"<th class=\"r\">Updated</th></tr></thead>\n<tbody>\n",+		stdout);+	walk("", 0);+	fputs("</tbody>\n</table>\n", stdout);+	pageend();+}++void+indexrow(const char *name)+{+	char desc[256], owner[256];+	git_repository *r = NULL;+	git_commit *commit = NULL;++	readdesc(name, desc, sizeof(desc));+	readfile(name, "owner", owner, sizeof(owner));+	fputs("<tr><td class=\"name\"><a href=\"", stdout);+	xmlencode(base);+	putchar('/');+	urlencode(name);+	fputs("\">", stdout);+	printname(name);+	fputs("</a></td><td class=\"desc sm\">", stdout);+	xmlencode(desc);+	fputs("</td><td class=\"owner sm\">", stdout);+	xmlencode(owner);+	fputs("</td>", stdout);+	if (openrepo(&r, name) >= 0 && headcommit(&commit, r) >= 0)+		printage(&git_commit_committer(commit)->when);+	else+		fputs("<td class=\"r age a4\">&mdash;</td>", stdout);+	fputs("</tr>\n", stdout);+	git_commit_free(commit);+	git_repository_free(r);+}++int+isrepo(const char *name)+{+	char path[PATH_MAX];+	struct stat st;++	if (!validname(name))+		return 0;+	if (bprintf(path, sizeof(path), "%s/%s.git", root, name) < 0)+		return 0;+	if (stat(path, &st) < 0)+		return 0;+	return S_ISDIR(st.st_mode);+}++int+loadref(git_reference *r)+{+	git_object *obj;+	int err;++	if ((err = git_reference_peel(&obj, r, GIT_OBJECT_COMMIT)) >= 0) {+		git_oid_cpy(&tip, git_object_id(obj));+		err = bprintf(ref, sizeof(ref), "%s",+			git_reference_shorthand(r));+		git_object_free(obj);+	}+	git_reference_free(r);+	return err;+}++void+logpage(void)+{+	git_revwalk *w = NULL;+	git_oid oid;+	long i;+	int n = 0, more = 0;++	pagebegin(200, reponame);+	repoheader("log");+	fputs("<table class=\"log\">\n<thead><tr><th>Date</th>"+		"<th>Message</th><th class=\"sm\">Author</th>"+		"<th class=\"r sm\">Files</th><th class=\"r\">+</th>"+		"<th class=\"r\">&minus;</th></tr></thead>\n<tbody>\n",+		stdout);+	if (*ref && git_revwalk_new(&w, repo) >= 0+	&& git_revwalk_push(w, &tip) >= 0) {+		git_revwalk_sorting(w, GIT_SORT_TIME);+		for (i = 0; i < ofs && git_revwalk_next(&oid, w) >= 0; i++)+			;+		for (; n < loglimit && git_revwalk_next(&oid, w) >= 0; n++)+			logrow(&oid);+		more = git_revwalk_next(&oid, w) >= 0;+	}+	fputs("</tbody>\n</table>\n", stdout);+	if (!n) {+		fputs(*ref ? "<p>No commits.</p>\n"+			: "<p>Empty repository.</p>\n", stdout);+	} else if (ofs || more) {+		fputs("<nav class=\"pager\">", stdout);+		if (ofs) {+			fputs("<a href=\"", stdout);+			printurl("log", ref, NULL);+			if (ofs > loglimit)+				printf("?ofs=%ld", ofs - loglimit);+			fputs("\">&larr; newer</a>", stdout);+		} else {+			fputs("<span>&larr; newer</span>", stdout);+		}+		printf("<span>%ld&ndash;%ld</span>", ofs + 1, ofs + n);+		if (more) {+			fputs("<a href=\"", stdout);+			printurl("log", ref, NULL);+			printf("?ofs=%ld\">older &rarr;</a>", ofs + n);+		} else {+			fputs("<span>older &rarr;</span>", stdout);+		}+		fputs("</nav>\n", stdout);+	}+	git_revwalk_free(w);+	pageend();+}++void+logrow(const git_oid *oid)+{+	git_commit *commit;+	git_diff *diff;+	git_diff_stats *stats = NULL;+	const git_signature *author;++	if (git_commit_lookup(&commit, repo, oid) < 0)+		return;+	author = git_commit_author(commit);+	fputs("<tr><td class=\"date\" title=\"", stdout);+	printtime(&author->when, "%Y-%m-%d %H:%M");+	fputs("\">", stdout);+	printtime(&author->when, "%Y-%m-%d");+	fputs("</td><td class=\"msg\"><a href=\"", stdout);+	printurl("commit", git_oid_tostr_s(oid), NULL);+	fputs("\">", stdout);+	xmlencode(git_commit_summary(commit));+	fputs("</a></td><td class=\"sm\">", stdout);+	xmlencode(author->name);+	if (commitdiff(&diff, commit) >= 0)+		git_diff_get_stats(&stats, diff);+	if (stats)+		printf("</td><td class=\"r num sm\">%zu</td>"+			"<td class=\"r num ins\">+%zu</td>"+			"<td class=\"r num del\">&minus;%zu</td></tr>\n",+			git_diff_stats_files_changed(stats),+			git_diff_stats_insertions(stats),+			git_diff_stats_deletions(stats));+	else+		fputs("</td><td class=\"sm\"></td><td></td><td></td></tr>\n",+			stdout);+	git_diff_stats_free(stats);+	git_diff_free(diff);+	git_commit_free(commit);+}++const char *+modestr(git_filemode_t mode)+{+	switch (mode) {+	case GIT_FILEMODE_TREE:            return "d---------";+	case GIT_FILEMODE_BLOB_EXECUTABLE: return "-rwxr-xr-x";+	case GIT_FILEMODE_LINK:            return "lrwxrwxrwx";+	case GIT_FILEMODE_COMMIT:          return "m---------";+	default:                           return "-rw-r--r--";+	}+}++void+notfound(void)+{+	pagebegin(404, "Not found");+	fputs("<main>\n<p>Not found.</p>\n", stdout);+	pageend();+}++int+openrepo(git_repository **r, const char *name)+{+	char path[PATH_MAX];++	if (bprintf(path, sizeof(path), "%s/%s.git", root, name) < 0)+		return -1;+	return git_repository_open_ext(r, path,+		GIT_REPOSITORY_OPEN_NO_SEARCH, NULL);+}++void+pagebegin(int status, const char *pagetitle)+{+	size_t i;++	httpheader(status, HTML);+	fputs("<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n"+		"<meta name=\"viewport\" "+		"content=\"width=device-width, initial-scale=1\">\n<title>",+		stdout);+	xmlencode(pagetitle);+	fputs("</title>\n<link rel=\"stylesheet\" href=\"", stdout);+	xmlencode(css);+	fputs("\">\n</head>\n<body>\n<div id=\"top\"><a class=\"brand\" "+		"href=\"", stdout);+	xmlencode(base);+	fputs("/\"><b>", stdout);+	xmlencode(mark);+	fputs("</b> ", stdout);+	xmlencode(title);+	fputs("</a><nav>", stdout);+	for (i = 0; i < LENGTH(services); i++) {+		if (!strcmp(services[i].name, service)) {+			fputs("<a class=\"on\" href=\"", stdout);+			xmlencode(base);+			putchar('/');+		} else {+			fputs("<a href=\"", stdout);+			xmlencode(services[i].url);+		}+		fputs("\">", stdout);+		xmlencode(services[i].name);+		fputs("</a>", stdout);+	}+	fputs("</nav></div>\n", stdout);+}++void+pageend(void)+{+	fputs("</main>\n</body>\n</html>\n", stdout);+}++void+printage(const git_time *t)+{+	static const char *units[] = {+		"minute", "hour", "day", "week", "month", "year"+	};+	static const long secs[] = {+		60, HOUR, DAY, 7 * DAY, 30 * DAY, 365 * DAY+	};+	static const long upto[] = {+		HOUR, DAY, 14 * DAY, 60 * DAY, 730 * DAY, LONG_MAX+	};+	static const long fade[] = { 7 * DAY, 30 * DAY, 180 * DAY, 730 * DAY };+	long d, n;+	size_t i, level;++	if ((d = (long)(time(NULL) - (time_t)t->time)) < 0)+		d = 0;+	for (i = 0; d >= upto[i]; i++)+		;+	if ((n = d / secs[i]) < 1)+		n = 1;+	for (level = 0; level < LENGTH(fade) && d >= fade[level]; level++)+		;+	printf("<td class=\"r age a%zu\" title=\"", level);+	printtime(t, "%Y-%m-%d %H:%M");+	printf("\">%ld %s%s</td>", n, units[i], n == 1 ? "" : "s");+}++void+printdiff(git_diff *diff)+{+	git_patch *patch;+	const git_diff_delta *delta;+	const git_diff_hunk *hunk;+	const git_diff_line *line;+	size_t i, h, l, nhunks, nlines, len;++	for (i = 0; i < git_diff_num_deltas(diff); i++) {+		if (git_patch_from_diff(&patch, diff, i) < 0)+			continue;+		delta = git_patch_get_delta(patch);+		printf("<h3 id=\"f%zu\">", i);+		if (strcmp(delta->old_file.path, delta->new_file.path)) {+			xmlencode(delta->old_file.path);+			fputs(" &rarr; ", stdout);+		}+		xmlencode(delta->new_file.path);+		fputs("</h3>\n<pre class=\"diff\">", stdout);+		if (delta->flags & GIT_DIFF_FLAG_BINARY)+			fputs("<span>Binary files differ.</span>", stdout);+		nhunks = git_patch_num_hunks(patch);+		for (h = 0; h < nhunks; h++) {+			if (git_patch_get_hunk(&hunk, &nlines, patch, h) < 0)+				break;+			len = hunk->header_len;+			while (len && hunk->header[len - 1] == '\n')+				len--;+			fputs("<span class=\"h\">", stdout);+			xmlencoden(hunk->header, len);+			fputs("</span>", stdout);+			for (l = 0; l < nlines; l++) {+				if (git_patch_get_line_in_hunk(&line, patch,+				    h, l) < 0)+					break;+				if (line->origin == GIT_DIFF_LINE_ADDITION)+					fputs("<span class=\"i\">+", stdout);+				else if (line->origin ==+				    GIT_DIFF_LINE_DELETION)+					fputs("<span class=\"d\">-", stdout);+				else if (line->origin == GIT_DIFF_LINE_CONTEXT)+					fputs("<span> ", stdout);+				else+					continue;+				len = line->content_len;+				if (len && line->content[len - 1] == '\n')+					len--;+				xmlencoden(line->content, len);+				fputs("</span>", stdout);+			}+		}+		fputs("</pre>\n", stdout);+		git_patch_free(patch);+	}+}++void+printname(const char *name)+{+	const char *p;++	if ((p = strrchr(name, '/'))) {+		fputs("<span class=\"dir\">", stdout);+		xmlencoden(name, p - name + 1);+		fputs("</span>", stdout);+		name = p + 1;+	}+	xmlencode(name);+}++void+printpath(const char *path)+{+	char prefix[PATH_MAX];+	const char *p, *q, *s;++	fputs("<p class=\"path\">", stdout);+	if ((s = strrchr(reponame, '/')))+		s++;+	else+		s = reponame;+	if (!*path) {+		xmlencode(s);+		return;+	}+	fputs("<a href=\"", stdout);+	printurl("tree", ref, NULL);+	fputs("\">", stdout);+	xmlencode(s);+	fputs("</a>", stdout);+	for (p = path; (q = strchr(p, '/')); p = q + 1) {+		if (bprintf(prefix, sizeof(prefix), "%.*s",+		    (int)(q - path), path) < 0)+			return;+		fputs(" / <a href=\"", stdout);+		printurl("tree", ref, prefix);+		fputs("\">", stdout);+		xmlencoden(p, q - p);+		fputs("</a>", stdout);+	}+	fputs(" / ", stdout);+	xmlencode(p);+}++void+printstat(git_diff *diff)+{+	git_patch *patch;+	size_t i, j, n, add, del, max = 0, tadd = 0, tdel = 0;+	size_t *st;++	n = git_diff_num_deltas(diff);+	if (!(st = calloc(n * 2 + 1, sizeof(*st))))+		return;+	for (i = 0; i < n; i++) {+		if (git_patch_from_diff(&patch, diff, i) < 0)+			continue;+		git_patch_line_stats(NULL, &st[i * 2], &st[i * 2 + 1], patch);+		git_patch_free(patch);+		if (st[i * 2] + st[i * 2 + 1] > max)+			max = st[i * 2] + st[i * 2 + 1];+	}+	fputs("<table class=\"stat\">\n", stdout);+	for (i = 0; i < n; i++) {+		add = st[i * 2];+		del = st[i * 2 + 1];+		tadd += add;+		tdel += del;+		printf("<tr><td><a href=\"#f%zu\">", i);+		xmlencode(git_diff_get_delta(diff, i)->new_file.path);+		printf("</a></td><td class=\"r num\">%zu</td>"+			"<td class=\"bar\"><span class=\"ins\">", add + del);+		if (max > (size_t)barwidth) {+			add = (add * barwidth + max - 1) / max;+			del = (del * barwidth + max - 1) / max;+		}+		for (j = 0; j < add; j++)+			putchar('+');+		fputs("</span><span class=\"del\">", stdout);+		for (j = 0; j < del; j++)+			putchar('-');+		fputs("</span></td></tr>\n", stdout);+	}+	printf("</table>\n<p class=\"total\">%zu file%s changed, "+		"<span class=\"ins\">+%zu</span> "+		"<span class=\"del\">&minus;%zu</span></p>\n",+		n, n == 1 ? "" : "s", tadd, tdel);+	free(st);+}++void+printtime(const git_time *t, const char *fmt)+{+	struct tm tm;+	time_t secs;+	char buf[32];++	secs = (time_t)t->time + (time_t)t->offset * 60;+	if (!gmtime_r(&secs, &tm))+		return;+	if (strftime(buf, sizeof(buf), fmt, &tm))+		fputs(buf, stdout);+}++void+printurl(const char *page, const char *r, const char *path)+{+	xmlencode(base);+	putchar('/');+	urlencode(reponame);+	printf("/%s", page);+	if (r && *r) {+		putchar('/');+		urlencode(r);+	}+	if (path && *path) {+		putchar('/');+		urlencode(path);+	}+}++void+rawpage(const char *path)+{+	git_commit *c = NULL;+	git_tree *tree = NULL;+	git_tree_entry *e = NULL;+	git_blob *blob = NULL;++	if (git_commit_lookup(&c, repo, &tip) < 0+	|| git_commit_tree(&tree, c) < 0+	|| git_tree_entry_bypath(&e, tree, path) < 0+	|| git_tree_entry_type(e) != GIT_OBJECT_BLOB+	|| git_blob_lookup(&blob, repo, git_tree_entry_id(e)) < 0) {+		notfound();+		goto end;+	}+	httpheader(200, git_blob_is_binary(blob) ? "application/octet-stream"+		: "text/plain; charset=utf-8");+	fwrite(git_blob_rawcontent(blob), 1, (size_t)git_blob_rawsize(blob),+		stdout);+end:+	git_blob_free(blob);+	git_tree_entry_free(e);+	git_tree_free(tree);+	git_commit_free(c);+}++void+readdesc(const char *name, char *buf, size_t len)+{+	readfile(name, "description", buf, len);+	if (!strncmp(buf, "Unnamed repository", 18))+		buf[0] = '\0';+}++void+readfile(const char *name, const char *file, char *buf, size_t len)+{+	char path[PATH_MAX];+	FILE *fp;++	buf[0] = '\0';+	if (bprintf(path, sizeof(path), "%s/%s.git/%s", root, name, file) < 0)+		return;+	if (!(fp = fopen(path, "r")))+		return;+	if (fgets(buf, (int)len, fp))+		buf[strcspn(buf, "\r\n")] = '\0';+	fclose(fp);+}++void+refrows(const char *glob)+{+	git_reference_iterator *it;+	git_reference *r;+	git_object *obj;+	const git_signature *author;++	if (git_reference_iterator_glob_new(&it, repo, glob) < 0)+		return;+	while (git_reference_next(&r, it) >= 0) {+		fputs("<tr><td class=\"name\"><a href=\"", stdout);+		printurl("log", git_reference_shorthand(r), NULL);+		fputs("\">", stdout);+		xmlencode(git_reference_shorthand(r));+		fputs("</a></td><td class=\"date\">", stdout);+		if (git_reference_peel(&obj, r, GIT_OBJECT_COMMIT) >= 0) {+			author = git_commit_author((git_commit *)obj);+			printtime(&author->when, "%Y-%m-%d %H:%M");+			fputs("</td><td class=\"sm\">", stdout);+			xmlencode(author->name);+			git_object_free(obj);+		} else {+			fputs("</td><td class=\"sm\">", stdout);+		}+		fputs("</td></tr>\n", stdout);+		git_reference_free(r);+	}+	git_reference_iterator_free(it);+}++void+refspage(void)+{+	pagebegin(200, reponame);+	repoheader("refs");+	printf("<h2>Branches</h2>\n<table class=\"refs\">\n%s", refshead);+	refrows("refs/heads/*");+	printf("</tbody>\n</table>\n<h2>Tags</h2>\n"+		"<table class=\"refs\">\n%s", refshead);+	refrows("refs/tags/*");+	fputs("</tbody>\n</table>\n", stdout);+	pageend();+}++void+repoheader(const char *tab)+{+	static const char *tabs[] = { "log", "tree", "refs" };+	char desc[256];+	size_t i;++	readdesc(reponame, desc, sizeof(desc));+	fputs("<header>\n<h1>", stdout);+	printname(reponame);+	fputs("</h1>\n", stdout);+	if (*desc) {+		fputs("<p class=\"desc\">", stdout);+		xmlencode(desc);+		fputs("</p>\n", stdout);+	}+	fputs("<p class=\"clone\"><span>git clone</span> ", stdout);+	xmlencode(clone);+	xmlencode(reponame);+	fputs(".git</p>\n<nav>", stdout);+	for (i = 0; i < LENGTH(tabs); i++) {+		printf("<a%s href=\"", strcmp(tab, tabs[i]) ? ""+			: " class=\"on\"");+		printurl(tabs[i], i < 2 ? ref : NULL, NULL);+		printf("\">%s</a>", tabs[i]);+	}+	if (*ref) {+		fputs("<span class=\"ref\">ref <a href=\"", stdout);+		printurl("refs", NULL, NULL);+		fputs("\">", stdout);+		xmlencode(ref);+		fputs("</a></span>", stdout);+	}+	fputs("</nav>\n</header>\n<main>\n", stdout);+}++void+route(char *path)+{+	git_reference *r;+	char *rest = NULL, *page, *arg, *cut;++	while (*path == '/')+		path++;+	for (cut = path + strlen(path); cut > path && cut[-1] == '/'; cut--)+		cut[-1] = '\0';+	if (!*path) {+		indexpage();+		return;+	}+	while (!isrepo(path)) {+		if (!(cut = strrchr(path, '/'))) {+			notfound();+			return;+		}+		if (rest)+			rest[-1] = '/';+		*cut = '\0';+		rest = cut + 1;+	}+	if (openrepo(&repo, path) < 0) {+		notfound();+		return;+	}+	reponame = path;+	if (git_repository_head(&r, repo) >= 0)+		loadref(r);++	page = rest ? rest : "log";+	if ((arg = strchr(page, '/')))+		*arg++ = '\0';+	else+		arg = "";+	if (!strcmp(page, "log")) {+		if (*arg && (findref(arg, &rest) < 0 || *rest))+			notfound();+		else+			logpage();+	} else if (!strcmp(page, "tree")) {+		if (*arg && findref(arg, &rest) < 0)+			notfound();+		else+			treepage(*arg ? rest : "");+	} else if (!strcmp(page, "raw")) {+		if (findref(arg, &rest) < 0 || !*rest)+			notfound();+		else+			rawpage(rest);+	} else if (!strcmp(page, "commit") && *arg) {+		commitpage(arg);+	} else if (!strcmp(page, "refs") && !*arg) {+		refspage();+	} else {+		notfound();+	}+	git_repository_free(repo);+}++void+treepage(const char *path)+{+	git_commit *c = NULL;+	git_tree *root = NULL, *tree = NULL;+	git_tree_entry *e = NULL;++	if (*ref && (git_commit_lookup(&c, repo, &tip) < 0+	|| git_commit_tree(&root, c) < 0))+		goto fail;+	if (*path) {+		if (!root || git_tree_entry_bypath(&e, root, path) < 0)+			goto fail;+		if (git_tree_entry_type(e) == GIT_OBJECT_BLOB) {+			filepage(path, e);+			goto end;+		}+		if (git_tree_entry_type(e) != GIT_OBJECT_TREE+		|| git_tree_lookup(&tree, repo, git_tree_entry_id(e)) < 0)+			goto fail;+	}+	pagebegin(200, reponame);+	repoheader("tree");+	printpath(path);+	fputs("</p>\n", stdout);+	if (root)+		treetable(tree ? tree : root, path);+	else+		fputs("<p>Empty repository.</p>\n", stdout);+	pageend();+	goto end;+fail:+	notfound();+end:+	git_tree_entry_free(e);+	git_tree_free(tree);+	git_tree_free(root);+	git_commit_free(c);+}++void+treerow(const git_tree_entry *e, const char *path)+{+	char child[PATH_MAX];+	const char *name;+	git_object_t type;+	git_blob *blob;++	name = git_tree_entry_name(e);+	type = git_tree_entry_type(e);+	if (bprintf(child, sizeof(child), "%s%s%s", path, *path ? "/" : "",+	    name) < 0)+		return;+	printf("<tr><td class=\"mode sm\">%s</td><td class=\"%s\">",+		modestr(git_tree_entry_filemode(e)),+		type == GIT_OBJECT_TREE ? "dirent" : "name");+	if (type == GIT_OBJECT_COMMIT) {+		xmlencode(name);+	} else {+		fputs("<a href=\"", stdout);+		printurl("tree", ref, child);+		fputs("\">", stdout);+		xmlencode(name);+		fputs(type == GIT_OBJECT_TREE ? "/</a>" : "</a>", stdout);+	}+	fputs("</td><td class=\"r num\">", stdout);+	if (type == GIT_OBJECT_BLOB+	&& git_blob_lookup(&blob, repo, git_tree_entry_id(e)) >= 0) {+		printf("%lluB", (unsigned long long)git_blob_rawsize(blob));+		git_blob_free(blob);+	}+	fputs("</td></tr>\n", stdout);+}++void+treetable(const git_tree *tree, const char *path)+{+	char parent[PATH_MAX];+	const git_tree_entry *e;+	const char *p;+	size_t i, n;+	int pass, isdir;++	fputs("<table class=\"tree\">\n<thead><tr><th class=\"sm\">Mode</th>"+		"<th>Name</th><th class=\"r\">Size</th></tr></thead>\n"+		"<tbody>\n", stdout);+	if (*path) {+		p = strrchr(path, '/');+		if (bprintf(parent, sizeof(parent), "%.*s",+		    p ? (int)(p - path) : 0, path) < 0)+			return;+		fputs("<tr><td class=\"mode sm\">d---------</td>"+			"<td class=\"dirent\"><a href=\"", stdout);+		printurl("tree", ref, parent);+		fputs("\">../</a></td><td></td></tr>\n", stdout);+	}+	n = git_tree_entrycount(tree);+	for (pass = 0; pass < 2; pass++) {+		for (i = 0; i < n; i++) {+			e = git_tree_entry_byindex(tree, i);+			isdir = git_tree_entry_type(e) == GIT_OBJECT_TREE;+			if (isdir == pass)+				continue;+			treerow(e, path);+		}+	}+	fputs("</tbody>\n</table>\n", stdout);+}++void+urlencode(const char *s)+{+	for (; *s; s++) {+		if (isalnum((unsigned char)*s) || strchr("-._~/", *s))+			putchar(*s);+		else+			printf("%%%02X", (unsigned char)*s);+	}+}++int+validname(const char *s)+{+	unsigned char c;+	int start = 1;++	if (!*s)+		return 0;+	for (; *s; s++) {+		c = *s;+		if (c == '/') {+			if (start)+				return 0;+			start = 1;+			continue;+		}+		if (start && !(isalnum(c) || c == '_'))+			return 0;+		if (!(isalnum(c) || c == '.' || c == '_' || c == '-'))+			return 0;+		start = 0;+	}+	return !start;+}++void+walk(const char *rel, int depth)+{+	char dir[PATH_MAX];+	struct dirent **ents;+	int i, n;++	if (depth > maxdepth)+		return;+	if (bprintf(dir, sizeof(dir), "%s/%s", root, rel) < 0)+		return;+	if ((n = scandir(dir, &ents, NULL, alphasort)) < 0)+		return;+	for (i = 0; i < n; i++) {+		walkent(dir, rel, ents[i]->d_name, depth);+		free(ents[i]);+	}+	free(ents);+}++void+walkent(const char *dir, const char *rel, const char *e, int depth)+{+	char path[PATH_MAX], name[PATH_MAX];+	struct stat st;+	size_t len;++	if (e[0] == '.')+		return;+	if (bprintf(path, sizeof(path), "%s/%s", dir, e) < 0)+		return;+	if (lstat(path, &st) < 0 || !S_ISDIR(st.st_mode))+		return;+	if (bprintf(name, sizeof(name), "%s%s%s", rel, *rel ? "/" : "", e) < 0)+		return;+	len = strlen(e);+	if (len <= 4 || strcmp(e + len - 4, ".git")) {+		walk(name, depth + 1);+		return;+	}+	name[strlen(name) - 4] = '\0';+	if (validname(name))+		indexrow(name);+}++void+xmlencode(const char *s)+{+	if (s)+		xmlencoden(s, strlen(s));+}++void+xmlencoden(const char *s, size_t len)+{+	for (; len; s++, len--) {+		switch (*s) {+		case '<':  fputs("&lt;", stdout);   break;+		case '>':  fputs("&gt;", stdout);   break;+		case '&':  fputs("&amp;", stdout);  break;+		case '"':  fputs("&quot;", stdout); break;+		case '\'': fputs("&#39;", stdout);  break;+		default:   putchar(*s);+		}+	}+}++/* main */+int+main(void)+{+	char path[PATH_MAX];+	const char *q;++	root = envor("PETAL_ROOT", root);+	css = envor("PETAL_CSS", css);+	clone = envor("PETAL_CLONE", clone);+	title = envor("PETAL_TITLE", title);+	base = envor("SCRIPT_NAME", "");+	q = envor("QUERY_STRING", "");+	if (!strncmp(q, "ofs=", 4) && (ofs = strtol(q + 4, NULL, 10)) < 0)+		ofs = 0;++	git_libgit2_init();+	if (bprintf(path, sizeof(path), "%s", envor("PATH_INFO", "")) < 0)+		notfound();+	else+		route(path);+	git_libgit2_shutdown();+	return 0;+}

petal/style.css

@@ -0,0 +1,335 @@+:root {+	--bg: #0d0f0d;+	--fg: #c8cec3;+	--hi: #eef2ea;+	--dim: #6c7569;+	--faint: #1c201c;+	--leaf: #9fd47c;+	--petal: #f08fae;+	--a0: #9fd47c;+	--a1: #b9cfa8;+	--a2: #8d9689;+	--a3: #626a5f;+	--a4: #424840;+}++@media (prefers-color-scheme: light) {+	:root {+		--bg: #fafaf7;+		--fg: #2a2e28;+		--hi: #000;+		--dim: #7a8277;+		--faint: #ecefe8;+		--leaf: #2f6d1a;+		--petal: #b0345c;+		--a0: #2f6d1a;+		--a1: #577a47;+		--a2: #7a8277;+		--a3: #a2a89f;+		--a4: #c4c8c1;+	}+}++body {+	max-width: 980px;+	margin: 0 auto;+	padding: 1.25rem 1.5rem 3rem;+	background: var(--bg);+	color: var(--fg);+	font: 14px/1.6 ui-monospace, "JetBrains Mono", "DejaVu Sans Mono", monospace;+}++a {+	color: var(--hi);+	text-decoration: none;+}++a:hover {+	color: var(--leaf);+}++#top {+	display: flex;+	gap: 2ch;+	padding-bottom: 0.75rem;+	border-bottom: 1px dashed var(--dim);+}++#top .brand b {+	color: var(--petal);+	font-weight: normal;+}++#top nav, header nav {+	display: flex;+	gap: 1ch;+}++#top nav a, header nav a {+	padding: 0 1ch;+	color: var(--dim);+}++#top nav a.on, header nav a.on {+	background: var(--leaf);+	color: var(--bg);+}++header {+	padding: 2.25rem 0 0;+}++h1 {+	margin: 0;+	font-size: 1.75rem;+	line-height: 1.2;+	letter-spacing: -0.02em;+	color: var(--hi);+}++.desc {+	color: var(--dim);+}++header .desc {+	margin: 0.35rem 0 0;+}++.clone {+	display: inline-block;+	margin: 1.1rem 0 0;+	padding: 0.4rem 1.5ch 0.4rem 1.25ch;+	background: var(--faint);+	border-left: 2px solid var(--leaf);+}++.clone span {+	color: var(--dim);+	user-select: none;+}++.clone span::before {+	content: "$ ";+	color: var(--leaf);+}++header nav {+	margin: 1.25rem 0 0;+}++header nav a {+	margin-left: -1ch;+	margin-right: 1ch;+}++main {+	padding-top: 1.5rem;+}++table {+	width: 100%;+	border-collapse: collapse;+}++th {+	padding: 0 2ch 0.4rem 0;+	text-align: left;+	font-weight: normal;+	color: var(--dim);+}++td {+	padding: 0.1rem 2ch 0.1rem 0;+	white-space: nowrap;+	vertical-align: top;+}++th:last-child, td:last-child {+	padding-right: 0;+}++tbody tr:hover td {+	background: var(--faint);+}++.r {+	text-align: right;+}++.desc, .msg {+	width: 100%;+	white-space: normal;+}++.msg a {+	color: var(--fg);+}++.dir, .owner, .date, .num {+	color: var(--dim);+}++.ins { color: var(--leaf); }+.del { color: var(--petal); }+.a0 { color: var(--a0); }+.a1 { color: var(--a1); }+.a2 { color: var(--a2); }+.a3 { color: var(--a3); }+.a4 { color: var(--a4); }++header nav .ref {+	margin-left: auto;+	color: var(--dim);+}++header nav .ref a {+	margin: 0;+	padding: 0;+	color: var(--hi);+}++h2 {+	margin: 1.75rem 0 0.5rem;+	font-size: 1em;+	color: var(--dim);+}++h2:first-child {+	margin-top: 0;+}++h2:not(.subject)::before {+	content: "## ";+}++.pager {+	display: flex;+	gap: 3ch;+	margin-top: 1.25rem;+	color: var(--dim);+}++.pager a {+	color: var(--leaf);+}++.path {+	margin: 0 0 1rem;+	color: var(--dim);+}++.path .info {+	float: right;+}++.mode, .num {+	color: var(--dim);+}++.dirent a {+	color: var(--leaf);+}++pre {+	margin: 0;+	font: inherit;+	overflow-x: auto;+}++.blob a {+	color: var(--a4);+	margin-right: 1ch;+	user-select: none;+}++.blob a:hover, .blob a:target {+	color: var(--leaf);+}++.subject {+	margin: 0 0 1rem;+	font-size: 1.15rem;+	color: var(--hi);+}++.body {+	margin: 0 0 1.25rem;+	color: var(--fg);+	white-space: pre-wrap;+}++.meta {+	width: auto;+	margin-bottom: 1.5rem;+}++.meta td:first-child {+	color: var(--dim);+}++.stat {+	width: auto;+}++.stat td {+	padding-top: 0;+	padding-bottom: 0;+}++.bar {+	letter-spacing: -0.05em;+}++.total {+	margin: 0.5rem 0 2rem;+	color: var(--dim);+}++h3 {+	margin: 1.75rem 0 0;+	padding: 0.3rem 1ch;+	font-size: 1em;+	font-weight: normal;+	color: var(--hi);+	background: var(--faint);+}++.diff {+	padding: 0.5rem 0;+}++.diff span {+	display: block;+}++.diff .h {+	color: var(--dim);+}++.diff .i {+	color: var(--leaf);+	background: color-mix(in srgb, var(--leaf) 8%, transparent);+}++.diff .d {+	color: var(--petal);+	background: color-mix(in srgb, var(--petal) 8%, transparent);+}++@media (max-width: 640px) {+	.sm {+		display: none;+	}+}++.diff span {+	min-height: 1.6em;+}++.tree .name, .tree .dirent {+	width: 100%;+}++.refs .name {+	width: 100%;+}