git.strcat.st

/strcat/sgit.git/ - summarytreelogarchive

subject
init
commit
ce011d67ca888fef8f7d9126f0bada06431f5a1e
date
2026-04-26T15:07:16Z
message
diff
 Makefile               |   11 +
 README.md              |   47 ++
 patches/markdown.patch |  315 ++++++++++++
 sgit.c                 | 1299 ++++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 1672 insertions(+)

diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..3fdf21b
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,11 @@
+PROG    = sgit
+CFLAGS  = -O2 -Wall -Wextra -Wstrict-prototypes -Wmissing-prototypes -Wpointer-arith
+LDFLAGS = -static
+
+all: $(PROG)
+
+$(PROG): sgit.c
+	$(CC) $(CFLAGS) $(LDFLAGS) -o $(PROG) sgit.c
+
+clean:
+	rm -f $(PROG)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..00cbc2b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,47 @@
+# sgit
+
+`sgit` builds static html pages from git repos.
+
+## building
+
+make dat shit
+
+run dat shit `./sgit repos out`
+
+if `repos` is a repo (`.git/` exists), it generates one site in `out`.
+
+if `repos` does not have a .git/ directory it will scan direct child directories and generates one 
+site per repo, plus a top level index
+
+## output layout
+- `out/index.html           lists repos 
+- `out/repo1/index.html`    summary/readme page
+- `out/repo1/files.html`    file tree
+- `out/repo1/history.html`  commit history
+- `out/repo1/commits/<sha1>.html`
+- `out/repo1/file/<filename>.html`
+
+## metadata
+
+it reads meta data from .git/owner or .git/cloneurl
+
+- `owner`     defaults to `unknown` if missing
+- `cloneurl`  shown when present; e.g.:
+```
+printf '%s\n' "https://git.larp.moe/dd/sgit.git > \
+/path/to/repo/.git/cloneurl
+```          
+
+## styling
+make a style.css in the root of the out directory.
+- `out/style.css`
+
+## caching
+
+each repo output directory stores `.sgit-cache` with the last generated `head`.
+if `head` has not changed, that repo is skipped.
+
+
+## See Also
+
+- [stagit](https://codemadness.org/git/stagit/)
diff --git a/patches/markdown.patch b/patches/markdown.patch
new file mode 100644
index 0000000..50fc8cd
--- /dev/null
+++ b/patches/markdown.patch
@@ -0,0 +1,315 @@
+--- a/sgit.c
++++ b/sgit.c
+@@ -6,6 +6,7 @@
+ #include <dirent.h>
+ #include <err.h>
+ #include <errno.h>
++#include <ctype.h>
+ #include <limits.h>
+ #include <stdarg.h>
+ #include <stdio.h>
+@@ -60,16 +61,19 @@
+ static void	 make_files_page(const struct repo *, const char *);
+ static char	*read_git_meta_file(const struct repo *, const char *);
+ static void	 make_index(const char *, struct repo *, size_t);
+-static void	 make_readme_page(const struct repo *, const char *);
++static void	 make_readme_page(const struct repo *, const char *, int);
+ static void	 make_repo_page(const struct repo *);
+ static char	*file_page_name(const char *);
++static int	 has_suffix_ci(const char *, const char *);
++static void	 render_markdown(FILE *, const char *);
++static void	 render_markdown_inline(FILE *, const char *);
+ static void	 render_tree(FILE *, const struct repo *, struct tree_node *,
+ 		    int);
+ static int	 repo_cmp(const void *, const void *);
+ static char	*read_cache_head(const char *);
+ static char	*read_cmd_output(const char *, ...);
+ static char	*read_file_head(const struct repo *, const char *);
+-static char	*read_readme(const struct repo *);
++static char	*read_readme(const struct repo *, int *);
+ static char	*repo_basename(const char *);
+ static int	 repo_changed(struct repo *);
+ static int	 repo_has_git(const char *);
+@@ -85,6 +89,7 @@
+ static void	 usage(void);
+ static void	 write_cache_head(const char *, const char *);
+ static void	 write_html_escaped(FILE *, const char *);
++static void	 write_html_escaped_len(FILE *, const char *, size_t);
+ static char	*write_shell_quoted(const char *);
+ 
+ /*
+@@ -584,7 +589,15 @@
+ static void
+ write_html_escaped(FILE *fp, const char *s)
+ {
+-	for (; *s != '\0'; s++) {
++	write_html_escaped_len(fp, s, strlen(s));
++}
++
++static void
++write_html_escaped_len(FILE *fp, const char *s, size_t len)
++{
++	size_t i;
++
++	for (i = 0; i < len; i++) {
+ 		switch (*s) {
+ 		case '&':
+ 			fputs("&amp;", fp);
+@@ -602,6 +615,7 @@
+ 			fputc((unsigned char)*s, fp);
+ 			break;
+ 		}
++		s++;
+ 	}
+ }
+ 
+@@ -676,14 +690,32 @@
+ 	free(qpath);
+ }
+ 
++static int
++has_suffix_ci(const char *s, const char *suffix)
++{
++	size_t i, ls, lsf;
++
++	ls = strlen(s);
++	lsf = strlen(suffix);
++	if (lsf > ls)
++		return 0;
++	for (i = 0; i < lsf; i++) {
++		if (tolower((unsigned char)s[ls - lsf + i]) !=
++		    tolower((unsigned char)suffix[i]))
++			return 0;
++	}
++	return 1;
++}
++
+ static char *
+-read_readme(const struct repo *r)
++read_readme(const struct repo *r, int *is_md)
+ {
+ 	const char *names[] = { "README.md", "README", "README.txt", "readme.md",
+ 	    "readme", NULL };
+ 	char *out, *qname, *qpath;
+ 	size_t i;
+ 
++	*is_md = 0;
+ 	qpath = write_shell_quoted(r->path);
+ 	if (qpath == NULL)
+ 		err(1, "malloc");
+@@ -697,6 +729,7 @@
+ 		if (out == NULL)
+ 			continue;
+ 		if (out[0] != '\0') {
++			*is_md = has_suffix_ci(names[i], ".md");
+ 			free(qpath);
+ 			return out;
+ 		}
+@@ -741,8 +774,167 @@
+ }
+ 
+ static void
+-make_readme_page(const struct repo *r, const char *readme)
++render_markdown_inline(FILE *fp, const char *line)
+ {
++	const char *code_end, *link_close, *link_end, *url_start;
++	const char *p;
++	size_t n;
++
++	p = line;
++	while (*p != '\0') {
++		if (*p == '`') {
++			code_end = strchr(p + 1, '`');
++			if (code_end != NULL) {
++				fputs("<code>", fp);
++				write_html_escaped_len(fp, p + 1,
++				    (size_t)(code_end - (p + 1)));
++				fputs("</code>", fp);
++				p = code_end + 1;
++				continue;
++			}
++		}
++		if (*p == '[') {
++			link_close = strchr(p + 1, ']');
++			if (link_close != NULL && *(link_close + 1) == '(') {
++				url_start = link_close + 2;
++				link_end = strchr(url_start, ')');
++				if (link_end != NULL) {
++					fputs("<a href=\"", fp);
++					write_html_escaped_len(fp, url_start,
++					    (size_t)(link_end - url_start));
++					fputs("\">", fp);
++					write_html_escaped_len(fp, p + 1,
++					    (size_t)(link_close - (p + 1)));
++					fputs("</a>", fp);
++					p = link_end + 1;
++					continue;
++				}
++			}
++		}
++		n = strcspn(p, "`[");
++		if (n == 0) {
++			write_html_escaped_len(fp, p, 1);
++			p++;
++		} else {
++			write_html_escaped_len(fp, p, n);
++			p += n;
++		}
++	}
++}
++
++static void
++render_markdown(FILE *fp, const char *md)
++{
++	char *line;
++	char *save;
++	char *text;
++	int in_code, in_list, in_p;
++
++	save = str_dup(md);
++	if (save == NULL)
++		err(1, "malloc");
++	in_code = 0;
++	in_list = 0;
++	in_p = 0;
++	line = strtok(save, "\n");
++	while (line != NULL) {
++		while (*line == ' ' || *line == '\t')
++			line++;
++		if (strcmp(line, "```") == 0) {
++			if (in_p) {
++				fputs("</p>\n", fp);
++				in_p = 0;
++			}
++			if (in_list) {
++				fputs("</ul>\n", fp);
++				in_list = 0;
++			}
++			if (!in_code)
++				fputs("<pre>", fp);
++			else
++				fputs("</pre>\n", fp);
++			in_code = !in_code;
++			line = strtok(NULL, "\n");
++			continue;
++		}
++		if (in_code) {
++			write_html_escaped(fp, line);
++			fputc('\n', fp);
++			line = strtok(NULL, "\n");
++			continue;
++		}
++		if (*line == '\0') {
++			if (in_p) {
++				fputs("</p>\n", fp);
++				in_p = 0;
++			}
++			if (in_list) {
++				fputs("</ul>\n", fp);
++				in_list = 0;
++			}
++			line = strtok(NULL, "\n");
++			continue;
++		}
++		if (*line == '#') {
++			int level;
++
++			if (in_p) {
++				fputs("</p>\n", fp);
++				in_p = 0;
++			}
++			if (in_list) {
++				fputs("</ul>\n", fp);
++				in_list = 0;
++			}
++			level = 0;
++			while (*line == '#' && level < 6) {
++				level++;
++				line++;
++			}
++			while (*line == ' ')
++				line++;
++			fprintf(fp, "<h%d>", level == 0 ? 1 : level);
++			render_markdown_inline(fp, line);
++			fprintf(fp, "</h%d>\n", level == 0 ? 1 : level);
++			line = strtok(NULL, "\n");
++			continue;
++		}
++		if ((line[0] == '-' || line[0] == '*') && line[1] == ' ') {
++			if (in_p) {
++				fputs("</p>\n", fp);
++				in_p = 0;
++			}
++			if (!in_list) {
++				fputs("<ul>\n", fp);
++				in_list = 1;
++			}
++			fputs("<li>", fp);
++			render_markdown_inline(fp, line + 2);
++			fputs("</li>\n", fp);
++			line = strtok(NULL, "\n");
++			continue;
++		}
++		if (!in_p) {
++			fputs("<p>", fp);
++			in_p = 1;
++		} else
++			fputs("<br>\n", fp);
++		text = line;
++		render_markdown_inline(fp, text);
++		line = strtok(NULL, "\n");
++	}
++	if (in_code)
++		fputs("</pre>\n", fp);
++	if (in_p)
++		fputs("</p>\n", fp);
++	if (in_list)
++		fputs("</ul>\n", fp);
++	free(save);
++}
++
++static void
++make_readme_page(const struct repo *r, const char *readme, int readme_md)
++{
+ 	char *html;
+ 	FILE *fp;
+ 
+@@ -784,9 +976,13 @@
+ 
+ 	if (readme[0] != '\0') {
+ 		fputs("<h2>readme</h2>\n", fp);
+-		fputs("<pre>", fp);
+-		write_html_escaped(fp, readme);
+-		fputs("</pre>\n", fp);
++		if (readme_md)
++			render_markdown(fp, readme);
++		else {
++			fputs("<pre>", fp);
++			write_html_escaped(fp, readme);
++			fputs("</pre>\n", fp);
++		}
+ 	}
+ 	fputs("</body>\n</html>\n", fp);
+ 
+@@ -1089,6 +1285,7 @@
+ 	char *commits, *files, *full_hash, *history, *line, *qpath, *readme;
+ 	char *short_hash, *author, *date, *subject;
+ 	FILE *fp;
++	int readme_md;
+ 
+ 	qpath = write_shell_quoted(r->path);
+ 	if (qpath == NULL)
+@@ -1106,11 +1303,11 @@
+ 		err(1, "malloc");
+ 	free(qpath);
+ 
+-	readme = read_readme(r);
++	readme = read_readme(r, &readme_md);
+ 	if (readme == NULL)
+ 		err(1, "malloc");
+ 
+-	make_readme_page(r, readme);
++	make_readme_page(r, readme, readme_md);
+ 	make_files_page(r, files);
+ 
+ 	history = join3(r->outdir, "/", "history.html");
diff --git a/sgit.c b/sgit.c
new file mode 100644
index 0000000..7544caf
--- /dev/null
+++ b/sgit.c
@@ -0,0 +1,1299 @@
+#define _POSIX_C_SOURCE 200809L
+
+#include <sys/types.h>
+#include <sys/stat.h>
+
+#include <dirent.h>
+#include <errno.h>
+#include <limits.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#ifndef PATH_MAX
+#define PATH_MAX 4096
+#endif
+
+#define COMMIT_LIMIT 100
+#define SEP '\x1f'
+
+struct repo {
+	char	*branch;
+	char	*cloneurl;
+	char	*head;
+	char	*last_author;
+	char	*last_date;
+	char	*last_subject;
+	char	*name;
+	char	*owner;
+	char	*outdir;
+	char	*path;
+	char	*rootlink_deep;
+	char	*rootlink_repo;
+	int	 changed;
+};
+
+struct tree_node {
+	struct tree_node **children;
+	char	*name;
+	char	*path;
+	size_t	 cap;
+	size_t	 nchildren;
+	int	 is_dir;
+};
+
+static const char *progname;
+
+static void	 err(int, const char *, ...);
+static void	 errx(int, const char *, ...);
+static void	 warn(const char *, ...);
+static void	 append_char(char **, size_t *, size_t *, char);
+static void	 append_str(char **, size_t *, size_t *, const char *);
+static void	 add_repo(struct repo **, size_t *, size_t *, const char *);
+static void	 discover_repos(const char *, struct repo **, size_t *, size_t *);
+static void	 ensure_dir(const char *);
+static void	 free_repos(struct repo *, size_t);
+static char	*join3(const char *, const char *, const char *);
+static void	 load_repo_meta(struct repo *);
+static void	 make_commit_page(const struct repo *, const char *, const char *,
+	        const char *, const char *, const char *);
+static void	 make_files_page(const struct repo *, const char *);
+static char	*read_git_meta_file(const struct repo *, const char *);
+static void	 make_index(const char *, struct repo *, size_t);
+static void	 make_readme_page(const struct repo *, const char *);
+static void	 make_repo_page(const struct repo *);
+static char	*file_page_name(const char *);
+static void	 render_tree(FILE *, const struct repo *, struct tree_node *,
+	        int);
+static int	 repo_cmp(const void *, const void *);
+static char	*read_cache_head(const char *);
+static char	*read_cmd_output(const char *, ...);
+static char	*read_file_head(const struct repo *, const char *);
+static char	*read_readme(const struct repo *);
+static char	*repo_basename(const char *);
+static int	 repo_changed(struct repo *);
+static int	 repo_has_git(const char *);
+static char	*split_field(char **);
+static int	 stat_is_dir(const char *);
+static char	*str_dup(const char *);
+static char	*trim_newline(char *);
+static struct tree_node *tree_add_path(struct tree_node *, const char *);
+static int	 tree_cmp(const void *, const void *);
+static void	 tree_free(struct tree_node *);
+static struct tree_node *tree_new(const char *, const char *, int);
+static struct tree_node *tree_root_from_files(const char *);
+static void	 usage(void);
+static void	 write_cache_head(const char *, const char *);
+static void	 write_html_escaped(FILE *, const char *);
+static char	*write_shell_quoted(const char *);
+
+int
+main(int argc, char *argv[])
+{
+	char *argv0;
+	const char *input, *output;
+	struct repo *repos;
+	size_t cap, i, nrepos;
+	int ch, multi;
+
+	argv0 = argv[0];
+	while ((ch = getopt(argc, argv, "")) != -1) {
+	    switch (ch) {
+	    default:
+	        usage();
+	    }
+	}
+	argc -= optind;
+	argv += optind;
+	progname = argv0;
+	if (argc == 0) {
+	    input = "repos";
+	    output = "out";
+	} else if (argc == 2) {
+	    input = argv[0];
+	    output = argv[1];
+	} else
+	    usage();
+
+	repos = NULL;
+	cap = 0;
+	nrepos = 0;
+	discover_repos(input, &repos, &nrepos, &cap);
+	if (nrepos == 0)
+	    errx(1, "no git repositories found in %s", input);
+	qsort(repos, nrepos, sizeof(struct repo), repo_cmp);
+
+	ensure_dir(output);
+	multi = nrepos > 1;
+
+	for (i = 0; i < nrepos; i++) {
+	    if (multi) {
+	        repos[i].outdir = join3(output, "/", repos[i].name);
+	        if (repos[i].outdir == NULL)
+	            err(1, "malloc");
+	    } else {
+	        repos[i].outdir = str_dup(output);
+	        if (repos[i].outdir == NULL)
+	            err(1, "malloc");
+	    }
+	    if (multi) {
+	        repos[i].rootlink_repo = str_dup("../index.html");
+	        repos[i].rootlink_deep = str_dup("../../index.html");
+	    } else {
+	        repos[i].rootlink_repo = str_dup("index.html");
+	        repos[i].rootlink_deep = str_dup("../index.html");
+	    }
+	    if (repos[i].rootlink_repo == NULL || repos[i].rootlink_deep == NULL)
+	        err(1, "malloc");
+	    load_repo_meta(&repos[i]);
+	    repos[i].changed = repo_changed(&repos[i]);
+	    if (repos[i].changed)
+	        make_repo_page(&repos[i]);
+	}
+
+	if (multi)
+	    make_index(output, repos, nrepos);
+
+	free_repos(repos, nrepos);
+	return 0;
+}
+
+static void
+usage(void)
+{
+	fprintf(stderr, "usage: %s [repos out]\n", progname);
+	exit(1);
+}
+
+static void
+warn(const char *fmt, ...)
+{
+	va_list ap;
+	int saved;
+
+	saved = errno;
+	if (progname != NULL && *progname != '\0')
+		fprintf(stderr, "%s: ", progname);
+	va_start(ap, fmt);
+	if (fmt != NULL && *fmt != '\0')
+		vfprintf(stderr, fmt, ap);
+	else
+		fputs("warning", stderr);
+	va_end(ap);
+	fprintf(stderr, ": %s\n", strerror(saved));
+}
+
+static void
+errx(int eval, const char *fmt, ...)
+{
+	va_list ap;
+
+	if (progname != NULL && *progname != '\0')
+		fprintf(stderr, "%s: ", progname);
+	va_start(ap, fmt);
+	if (fmt != NULL && *fmt != '\0')
+		vfprintf(stderr, fmt, ap);
+	else
+		fputs("error", stderr);
+	va_end(ap);
+	fputc('\n', stderr);
+	exit(eval);
+}
+
+static void
+err(int eval, const char *fmt, ...)
+{
+	va_list ap;
+	int saved;
+
+	saved = errno;
+	if (progname != NULL && *progname != '\0')
+		fprintf(stderr, "%s: ", progname);
+	va_start(ap, fmt);
+	if (fmt != NULL && *fmt != '\0')
+		vfprintf(stderr, fmt, ap);
+	else
+		fputs("error", stderr);
+	va_end(ap);
+	fprintf(stderr, ": %s\n", strerror(saved));
+	exit(eval);
+}
+
+static char *
+str_dup(const char *s)
+{
+	char *out;
+	size_t len;
+
+	len = strlen(s);
+	out = malloc(len + 1);
+	if (out == NULL)
+	    return NULL;
+	memcpy(out, s, len + 1);
+	return out;
+}
+
+static char *
+trim_newline(char *s)
+{
+	size_t len;
+
+	len = strlen(s);
+	while (len > 0 && (s[len - 1] == '\n' || s[len - 1] == '\r')) {
+	    s[len - 1] = '\0';
+	    len--;
+	}
+	return s;
+}
+
+static int
+stat_is_dir(const char *path)
+{
+	struct stat st;
+
+	if (stat(path, &st) == -1)
+	    return 0;
+	return S_ISDIR(st.st_mode);
+}
+
+static char *
+join3(const char *a, const char *b, const char *c)
+{
+	char *out;
+	size_t la, lb, lc;
+
+	la = strlen(a);
+	lb = strlen(b);
+	lc = strlen(c);
+	out = malloc(la + lb + lc + 1);
+	if (out == NULL)
+	    return NULL;
+	memcpy(out, a, la);
+	memcpy(out + la, b, lb);
+	memcpy(out + la + lb, c, lc);
+	out[la + lb + lc] = '\0';
+	return out;
+}
+
+static int
+repo_has_git(const char *path)
+{
+	char *dotgit;
+	int ok;
+
+	dotgit = join3(path, "/", ".git");
+	if (dotgit == NULL)
+	    err(1, "malloc");
+	ok = stat_is_dir(dotgit);
+	free(dotgit);
+	return ok;
+}
+
+static char *
+repo_basename(const char *path)
+{
+	const char *p;
+
+	p = strrchr(path, '/');
+	if (p == NULL)
+	    return str_dup(path);
+	if (*(p + 1) == '\0') {
+	    for (; p > path && *p == '/'; p--)
+	        continue;
+	    for (; p > path && *(p - 1) != '/'; p--)
+	        continue;
+	    return str_dup(p);
+	}
+	return str_dup(p + 1);
+}
+
+static void
+append_char(char **buf, size_t *len, size_t *cap, char c)
+{
+	char *tmp;
+
+	if (*len + 2 > *cap) {
+	    if (*cap == 0)
+	        *cap = 128;
+	    else
+	        *cap *= 2;
+	    tmp = realloc(*buf, *cap);
+	    if (tmp == NULL)
+	        err(1, "realloc");
+	    *buf = tmp;
+	}
+	(*buf)[*len] = c;
+	(*len)++;
+	(*buf)[*len] = '\0';
+}
+
+static void
+append_str(char **buf, size_t *len, size_t *cap, const char *s)
+{
+	while (*s != '\0') {
+	    append_char(buf, len, cap, *s);
+	    s++;
+	}
+}
+
+static char *
+write_shell_quoted(const char *s)
+{
+	char *out;
+	size_t cap, len;
+
+	out = NULL;
+	cap = 0;
+	len = 0;
+	append_char(&out, &len, &cap, '\'');
+	while (*s != '\0') {
+	    if (*s == '\'')
+	        append_str(&out, &len, &cap, "'\\''");
+	    else
+	        append_char(&out, &len, &cap, *s);
+	    s++;
+	}
+	append_char(&out, &len, &cap, '\'');
+	return out;
+}
+
+static char *
+read_cmd_output(const char *fmt, ...)
+{
+	va_list ap;
+	char line[4096], *cmd, *out;
+	size_t cap, len;
+	FILE *fp;
+	int n;
+
+	cmd = NULL;
+	n = 0;
+	va_start(ap, fmt);
+	n = vsnprintf(NULL, 0, fmt, ap);
+	va_end(ap);
+	if (n < 0)
+	    return NULL;
+	cmd = malloc((size_t)n + 1);
+	if (cmd == NULL)
+	    err(1, "malloc");
+	va_start(ap, fmt);
+	vsnprintf(cmd, (size_t)n + 1, fmt, ap);
+	va_end(ap);
+
+	fp = popen(cmd, "r");
+	free(cmd);
+	if (fp == NULL)
+	    return NULL;
+
+	out = NULL;
+	cap = 0;
+	len = 0;
+	while (fgets(line, sizeof(line), fp) != NULL)
+	    append_str(&out, &len, &cap, line);
+	if (pclose(fp) == -1) {
+	    free(out);
+	    return NULL;
+	}
+	if (out == NULL) {
+	    out = str_dup("");
+	    if (out == NULL)
+	        err(1, "malloc");
+	}
+	return out;
+}
+
+static void
+add_repo(struct repo **repos, size_t *nrepos, size_t *cap, const char *path)
+{
+	struct repo *tmp;
+	char *name;
+
+	name = repo_basename(path);
+	if (name == NULL)
+	    err(1, "malloc");
+	if (*nrepos + 1 > *cap) {
+	    if (*cap == 0)
+	        *cap = 8;
+	    else
+	        *cap *= 2;
+	    tmp = realloc(*repos, *cap * sizeof(struct repo));
+	    if (tmp == NULL)
+	        err(1, "realloc");
+	    *repos = tmp;
+	}
+	(*repos)[*nrepos].branch = NULL;
+	(*repos)[*nrepos].cloneurl = NULL;
+	(*repos)[*nrepos].head = NULL;
+	(*repos)[*nrepos].last_author = NULL;
+	(*repos)[*nrepos].last_date = NULL;
+	(*repos)[*nrepos].last_subject = NULL;
+	(*repos)[*nrepos].name = name;
+	(*repos)[*nrepos].owner = NULL;
+	(*repos)[*nrepos].outdir = NULL;
+	(*repos)[*nrepos].path = str_dup(path);
+	(*repos)[*nrepos].rootlink_deep = NULL;
+	(*repos)[*nrepos].rootlink_repo = NULL;
+	if ((*repos)[*nrepos].path == NULL)
+	    err(1, "malloc");
+	(*repos)[*nrepos].changed = 1;
+	(*nrepos)++;
+}
+
+static void
+discover_repos(const char *input, struct repo **repos, size_t *nrepos, size_t *cap)
+{
+	struct dirent *de;
+	char *candidate;
+	DIR *dp;
+
+	if (repo_has_git(input)) {
+	    add_repo(repos, nrepos, cap, input);
+	    return;
+	}
+	if (!stat_is_dir(input))
+	    errx(1, "%s is not a directory", input);
+
+	dp = opendir(input);
+	if (dp == NULL)
+	    err(1, "opendir %s", input);
+	while ((de = readdir(dp)) != NULL) {
+	    if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0)
+	        continue;
+	    candidate = join3(input, "/", de->d_name);
+	    if (candidate == NULL)
+	        err(1, "malloc");
+	    if (repo_has_git(candidate))
+	        add_repo(repos, nrepos, cap, candidate);
+	    free(candidate);
+	}
+	if (closedir(dp) == -1)
+	    err(1, "closedir %s", input);
+}
+
+static int
+repo_cmp(const void *a, const void *b)
+{
+	const struct repo *ra, *rb;
+
+	ra = a;
+	rb = b;
+	return strcmp(ra->name, rb->name);
+}
+
+static void
+ensure_dir(const char *path)
+{
+	char buf[PATH_MAX];
+	char *p;
+	size_t len;
+
+	len = strlen(path);
+	if (len == 0)
+	    errx(1, "empty path");
+	if (len >= sizeof(buf))
+	    errx(1, "path too long: %s", path);
+	memcpy(buf, path, len + 1);
+
+	for (p = buf + 1; *p != '\0'; p++) {
+	    if (*p != '/')
+	        continue;
+	    *p = '\0';
+	    if (mkdir(buf, 0755) == -1 && errno != EEXIST)
+	        err(1, "mkdir %s", buf);
+	    *p = '/';
+	}
+	if (mkdir(buf, 0755) == -1 && errno != EEXIST)
+	    err(1, "mkdir %s", buf);
+}
+
+static char *
+read_cache_head(const char *outdir)
+{
+	char buf[256], *path;
+	FILE *fp;
+
+	path = join3(outdir, "/", ".sgit-cache");
+	if (path == NULL)
+	    err(1, "malloc");
+	fp = fopen(path, "r");
+	free(path);
+	if (fp == NULL)
+	    return NULL;
+	if (fgets(buf, sizeof(buf), fp) == NULL) {
+	    fclose(fp);
+	    return NULL;
+	}
+	if (fclose(fp) == EOF)
+	    warn("fclose cache");
+	trim_newline(buf);
+	return str_dup(buf);
+}
+
+static void
+write_cache_head(const char *outdir, const char *head)
+{
+	char *path;
+	FILE *fp;
+
+	path = join3(outdir, "/", ".sgit-cache");
+	if (path == NULL)
+	    err(1, "malloc");
+	fp = fopen(path, "w");
+	if (fp == NULL)
+	    err(1, "fopen %s", path);
+	fprintf(fp, "%s\n", head);
+	if (fclose(fp) == EOF)
+	    err(1, "fclose %s", path);
+	free(path);
+}
+
+static void
+load_repo_meta(struct repo *r)
+{
+	char *fmt, *out, *p;
+	char *author, *date, *qpath, *subject;
+
+	qpath = write_shell_quoted(r->path);
+	if (qpath == NULL)
+	    err(1, "malloc");
+
+	out = read_cmd_output("git -C %s rev-parse --verify HEAD 2>/dev/null", qpath);
+	if (out == NULL)
+	    errx(1, "failed to get HEAD for %s", r->path);
+	trim_newline(out);
+	r->head = out;
+
+	out = read_cmd_output("git -C %s rev-parse --abbrev-ref HEAD 2>/dev/null", qpath);
+	if (out == NULL)
+	    errx(1, "failed to get branch for %s", r->path);
+	trim_newline(out);
+	r->branch = out;
+
+	fmt = "%ad%x1f%an%x1f%s";
+	out = read_cmd_output("git -C %s log -1 --date=iso --pretty=format:%s 2>/dev/null",
+	    qpath, fmt);
+	if (out == NULL)
+	    errx(1, "failed to get last commit for %s", r->path);
+	trim_newline(out);
+
+	p = out;
+	if ((date = split_field(&p)) == NULL)
+	    errx(1, "invalid git log output");
+	if ((author = split_field(&p)) == NULL)
+	    errx(1, "invalid git log output");
+	if ((subject = split_field(&p)) == NULL)
+	    errx(1, "invalid git log output");
+
+	r->last_date = str_dup(date);
+	if (r->last_date == NULL)
+	    err(1, "malloc");
+	r->last_author = str_dup(author);
+	if (r->last_author == NULL)
+	    err(1, "malloc");
+	r->last_subject = str_dup(subject);
+	if (r->last_subject == NULL)
+	    err(1, "malloc");
+	r->owner = read_git_meta_file(r, "owner");
+	if (r->owner == NULL)
+	    err(1, "malloc");
+	r->cloneurl = read_git_meta_file(r, "cloneurl");
+	if (r->cloneurl == NULL)
+	    err(1, "malloc");
+
+	free(out);
+	free(qpath);
+}
+
+static char *
+split_field(char **s)
+{
+	char *field, *p;
+
+	if (*s == NULL)
+	    return NULL;
+	field = *s;
+	p = strchr(field, SEP);
+	if (p == NULL) {
+	    *s = NULL;
+	    return field;
+	}
+	*p = '\0';
+	*s = p + 1;
+	return field;
+}
+
+static int
+repo_changed(struct repo *r)
+{
+	char *cached;
+	int changed;
+
+	ensure_dir(r->outdir);
+	cached = read_cache_head(r->outdir);
+	if (cached == NULL)
+	    return 1;
+	changed = strcmp(cached, r->head) != 0;
+	free(cached);
+	return changed;
+}
+
+static void
+write_html_escaped(FILE *fp, const char *s)
+{
+	for (; *s != '\0'; s++) {
+	    switch (*s) {
+	    case '&':
+	        fputs("&amp;", fp);
+	        break;
+	    case '<':
+	        fputs("&lt;", fp);
+	        break;
+	    case '>':
+	        fputs("&gt;", fp);
+	        break;
+	    case '\"':
+	        fputs("&quot;", fp);
+	        break;
+	    default:
+	        fputc((unsigned char)*s, fp);
+	        break;
+	    }
+	}
+}
+
+static void
+make_commit_page(const struct repo *r, const char *hash, const char *short_hash,
+    const char *date, const char *author, const char *subject)
+{
+	char *dir, *html, *patch, *qhash, *qpath, *tmp;
+	FILE *fp;
+
+	dir = join3(r->outdir, "/", "commits");
+	if (dir == NULL)
+	    err(1, "malloc");
+	ensure_dir(dir);
+	tmp = join3(dir, "/", hash);
+	if (tmp == NULL)
+	    err(1, "malloc");
+	html = join3(tmp, "", ".html");
+	if (html == NULL)
+	    err(1, "malloc");
+	free(tmp);
+
+	qpath = write_shell_quoted(r->path);
+	qhash = write_shell_quoted(hash);
+	if (qpath == NULL || qhash == NULL)
+	    err(1, "malloc");
+	patch = read_cmd_output("git -C %s show --date=short --pretty=format: "
+	    "--patch --stat %s 2>/dev/null", qpath, qhash);
+	if (patch == NULL)
+	    patch = str_dup("");
+	if (patch == NULL)
+	    err(1, "malloc");
+
+	fp = fopen(html, "w");
+	if (fp == NULL)
+	    err(1, "fopen %s", html);
+
+	fputs("<!doctype html>\n<html lang=\"en\">\n<head>\n", fp);
+	fputs("<meta charset=\"utf-8\">\n", fp);
+	fputs("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n", fp);
+	fputs("<link rel=\"stylesheet\" href=\"../../style.css\">\n", fp);
+	fputs("<title>", fp);
+	write_html_escaped(fp, short_hash);
+	fputs(" - ", fp);
+	write_html_escaped(fp, r->name);
+	fputs("</title>\n", fp);
+	fputs("</head>\n<body>\n<h1>", fp);
+	write_html_escaped(fp, short_hash);
+	fputs("</h1>\n<p><a href=\"../index.html\">summary</a> | "
+	    "<a href=\"../files.html\">files</a> | "
+	    "<a href=\"../history.html\">history</a> | ", fp);
+	write_html_escaped(fp, r->name);
+	fputs("</p>\n<p>commit: <code>", fp);
+	write_html_escaped(fp, hash);
+	fputs("</code><br>author: ", fp);
+	write_html_escaped(fp, author);
+	fputs("<br>date: ", fp);
+	write_html_escaped(fp, date);
+	fputs("<br>subject: ", fp);
+	write_html_escaped(fp, subject);
+	fputs("</p>\n", fp);
+	fputs("<h2>Diff</h2>\n<pre>", fp);
+	write_html_escaped(fp, patch);
+	fputs("</pre>\n</body>\n</html>\n", fp);
+
+	if (fclose(fp) == EOF)
+	    err(1, "fclose %s", html);
+	free(dir);
+	free(html);
+	free(patch);
+	free(qhash);
+	free(qpath);
+}
+
+static char *
+read_readme(const struct repo *r)
+{
+	const char *names[] = { "README.md", "README", "README.txt", "readme.md",
+	    "readme", NULL };
+	char *out, *qname, *qpath;
+	size_t i;
+
+	qpath = write_shell_quoted(r->path);
+	if (qpath == NULL)
+	    err(1, "malloc");
+	for (i = 0; names[i] != NULL; i++) {
+	    qname = write_shell_quoted(names[i]);
+	    if (qname == NULL)
+	        err(1, "malloc");
+	    out = read_cmd_output("git -C %s show HEAD:%s 2>/dev/null", qpath,
+	        qname);
+	    free(qname);
+	    if (out == NULL)
+	        continue;
+	    if (out[0] != '\0') {
+	        free(qpath);
+	        return out;
+	    }
+	    free(out);
+	}
+	free(qpath);
+	return str_dup("");
+}
+
+static char *
+read_git_meta_file(const struct repo *r, const char *name)
+{
+	char *file, *gitdir, *out;
+	FILE *fp;
+	size_t n;
+
+	out = NULL;
+	n = 0;
+	gitdir = join3(r->path, "/", ".git");
+	if (gitdir == NULL)
+	    err(1, "malloc");
+	file = join3(gitdir, "/", name);
+	if (file == NULL)
+	    err(1, "malloc");
+	free(gitdir);
+
+	fp = fopen(file, "r");
+	free(file);
+	if (fp == NULL)
+	    return str_dup("");
+
+	if (getline(&out, &n, fp) == -1) {
+	    if (fclose(fp) == EOF)
+	        warn("fclose metadata");
+	    free(out);
+	    return str_dup("");
+	}
+	if (fclose(fp) == EOF)
+	    warn("fclose metadata");
+	trim_newline(out);
+	return out;
+}
+
+static void
+make_readme_page(const struct repo *r, const char *readme)
+{
+	char *html;
+	FILE *fp;
+
+	html = join3(r->outdir, "/", "index.html");
+	if (html == NULL)
+	    err(1, "malloc");
+	fp = fopen(html, "w");
+	if (fp == NULL)
+	    err(1, "fopen %s", html);
+
+	fputs("<!doctype html>\n<html lang=\"en\">\n<head>\n", fp);
+	fputs("<meta charset=\"utf-8\">\n", fp);
+	fputs("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n", fp);
+	fputs("<link rel=\"stylesheet\" href=\"../style.css\">\n", fp);
+	fputs("<title>", fp);
+	write_html_escaped(fp, r->name);
+	fputs(" summary</title>\n", fp);
+	fputs("</head>\n<body>\n", fp);
+	if (strcmp(r->rootlink_repo, "index.html") != 0)
+	    fputs("<p><a class=\"index-link\" href=\"../index.html\">index</a></p>\n", fp);
+	fputs("<h1>", fp);
+	write_html_escaped(fp, r->name);
+	fputs("</h1>\n<p><a href=\"index.html\">summary</a> | <a href=\"files.html\">files</a>", fp);
+	fputs(" | <a href=\"history.html\">history</a></p>\n<p class=\"meta\">", fp);
+	fputs("<span class=\"meta-k\">branch</span> : ", fp);
+	write_html_escaped(fp, r->branch);
+	fputs("<br><span class=\"meta-k\">head</span> : ", fp);
+	write_html_escaped(fp, r->head);
+	fputs("<br><span class=\"meta-k\">owner</span> : ", fp);
+	if (r->owner[0] == '\0')
+	    fputs("unknown", fp);
+	else
+	    write_html_escaped(fp, r->owner);
+	if (r->cloneurl[0] != '\0') {
+	    fputs("<br><span class=\"meta-k\">clone</span> : git clone ", fp);
+	    write_html_escaped(fp, r->cloneurl);
+	}
+	fputs("</p>\n", fp);
+
+	if (readme[0] != '\0') {
+	    fputs("<h2>readme</h2>\n", fp);
+	    fputs("<pre>", fp);
+	    write_html_escaped(fp, readme);
+	    fputs("</pre>\n", fp);
+	}
+	fputs("</body>\n</html>\n", fp);
+
+	if (fclose(fp) == EOF)
+	    err(1, "fclose %s", html);
+	free(html);
+}
+static char *
+file_page_name(const char *path)
+{
+	const char *base;
+	char *out;
+
+	base = strrchr(path, '/');
+	if (base == NULL)
+	    base = path;
+	else
+	    base++;
+	out = join3(base, "", ".html");
+	if (out == NULL)
+	    err(1, "malloc");
+	return out;
+}
+
+static char *
+read_file_head(const struct repo *r, const char *path)
+{
+	char *qpath, *qspec, *spec, *out;
+
+	spec = join3("HEAD:", "", path);
+	if (spec == NULL)
+	    err(1, "malloc");
+	qpath = write_shell_quoted(r->path);
+	qspec = write_shell_quoted(spec);
+	free(spec);
+	if (qpath == NULL || qspec == NULL)
+	    err(1, "malloc");
+	out = read_cmd_output("git -C %s show %s 2>/dev/null", qpath, qspec);
+	if (out == NULL)
+	    out = str_dup("");
+	if (out == NULL)
+	    err(1, "malloc");
+	free(qpath);
+	free(qspec);
+	return out;
+}
+
+static struct tree_node *
+tree_new(const char *name, const char *path, int is_dir)
+{
+	struct tree_node *n;
+
+	n = calloc(1, sizeof(*n));
+	if (n == NULL)
+	    err(1, "calloc");
+	n->name = str_dup(name);
+	if (n->name == NULL)
+	    err(1, "malloc");
+	n->path = str_dup(path);
+	if (n->path == NULL)
+	    err(1, "malloc");
+	n->is_dir = is_dir;
+	return n;
+}
+
+static int
+tree_cmp(const void *a, const void *b)
+{
+	const struct tree_node *na, *nb;
+	const struct tree_node *const *pa, *const *pb;
+
+	pa = a;
+	pb = b;
+	na = *pa;
+	nb = *pb;
+	if (na->is_dir != nb->is_dir)
+	    return nb->is_dir - na->is_dir;
+	return strcmp(na->name, nb->name);
+}
+
+static struct tree_node *
+tree_add_path(struct tree_node *root, const char *path)
+{
+	char *copy, *curpath, *part, *slash;
+	struct tree_node *cur, *next;
+	size_t i;
+
+	copy = str_dup(path);
+	curpath = str_dup("");
+	if (copy == NULL || curpath == NULL)
+	    err(1, "malloc");
+	cur = root;
+	part = copy;
+
+	while (part != NULL && *part != '\0') {
+	    int is_dir;
+
+	    slash = strchr(part, '/');
+	    is_dir = slash != NULL;
+	    if (slash != NULL)
+	        *slash = '\0';
+
+	    if (curpath[0] == '\0')
+	        next = tree_new(part, part, is_dir);
+	    else {
+	        char *tmp;
+
+	        tmp = join3(curpath, "/", part);
+	        if (tmp == NULL)
+	            err(1, "malloc");
+	        next = tree_new(part, tmp, is_dir);
+	        free(tmp);
+	    }
+
+	    for (i = 0; i < cur->nchildren; i++) {
+	        if (strcmp(cur->children[i]->name, next->name) == 0 &&
+	            cur->children[i]->is_dir == next->is_dir)
+	            break;
+	    }
+	    if (i == cur->nchildren) {
+	        struct tree_node **tmp_children;
+
+	        if (cur->nchildren + 1 > cur->cap) {
+	            if (cur->cap == 0)
+	                cur->cap = 8;
+	            else
+	                cur->cap *= 2;
+	            tmp_children = realloc(cur->children,
+	                cur->cap * sizeof(*cur->children));
+	            if (tmp_children == NULL)
+	                err(1, "realloc");
+	            cur->children = tmp_children;
+	        }
+	        cur->children[cur->nchildren++] = next;
+	        cur = next;
+	    } else {
+	        tree_free(next);
+	        cur = cur->children[i];
+	    }
+
+	    free(curpath);
+	    curpath = str_dup(cur->path);
+	    if (curpath == NULL)
+	        err(1, "malloc");
+	    part = slash == NULL ? NULL : slash + 1;
+	}
+
+	free(curpath);
+	free(copy);
+	return root;
+}
+
+static struct tree_node *
+tree_root_from_files(const char *files)
+{
+	char *copy, *line;
+	struct tree_node *root;
+
+	root = tree_new("", "", 1);
+	copy = str_dup(files);
+	if (copy == NULL)
+	    err(1, "malloc");
+	line = strtok(copy, "\n");
+	while (line != NULL) {
+	    tree_add_path(root, line);
+	    line = strtok(NULL, "\n");
+	}
+	free(copy);
+	return root;
+}
+
+static void
+tree_free(struct tree_node *n)
+{
+	size_t i;
+
+	if (n == NULL)
+	    return;
+	for (i = 0; i < n->nchildren; i++)
+	    tree_free(n->children[i]);
+	free(n->children);
+	free(n->name);
+	free(n->path);
+	free(n);
+}
+
+static void
+render_tree(FILE *fp, const struct repo *r, struct tree_node *n, int depth)
+{
+	char *blob, *html, *name;
+	char *content;
+	FILE *bfp;
+	size_t i;
+
+	if (!n->is_dir) {
+	    name = file_page_name(n->path);
+	    fputs("<li><a href=\"file/", fp);
+	    write_html_escaped(fp, name);
+	    fputs("\"><code>", fp);
+	    write_html_escaped(fp, n->path);
+	    fputs("</code></a></li>\n", fp);
+
+	    blob = join3(r->outdir, "/file/", name);
+	    if (blob == NULL)
+	        err(1, "malloc");
+	    html = blob;
+	    content = read_file_head(r, n->path);
+	    bfp = fopen(html, "w");
+	    if (bfp == NULL)
+	        err(1, "fopen %s", html);
+	    fputs("<!doctype html>\n<html lang=\"en\">\n<head>\n", bfp);
+	    fputs("<meta charset=\"utf-8\">\n", bfp);
+	    fputs("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n", bfp);
+	    fputs("<link rel=\"stylesheet\" href=\"../../style.css\">\n", bfp);
+	    fputs("<title>", bfp);
+	    write_html_escaped(bfp, n->path);
+	    fputs(" - ", bfp);
+	    write_html_escaped(bfp, r->name);
+	    fputs("</title>\n", bfp);
+	    fputs("</head>\n<body>\n<p><a href=\"../index.html\">summary</a> | "
+	        "<a href=\"../files.html\">files</a> | "
+	        "<a href=\"../history.html\">history</a></p>\n<h1><code>",
+	        bfp);
+	    write_html_escaped(bfp, n->path);
+	    fputs("</code></h1>\n<pre>", bfp);
+	    write_html_escaped(bfp, content);
+	    fputs("</pre>\n</body>\n</html>\n", bfp);
+	    if (fclose(bfp) == EOF)
+	        err(1, "fclose %s", html);
+	    free(content);
+	    free(name);
+	    free(html);
+	    return;
+	}
+
+	if (depth == 0)
+	    fputs("<ul>\n", fp);
+	else {
+	    fputs("<li><code>", fp);
+	    write_html_escaped(fp, n->name);
+	    fputs("/</code>\n<ul>\n", fp);
+	}
+	qsort(n->children, n->nchildren, sizeof(*n->children), tree_cmp);
+	for (i = 0; i < n->nchildren; i++)
+	    render_tree(fp, r, n->children[i], depth + 1);
+	if (depth == 0)
+	    fputs("</ul>\n", fp);
+	else
+	    fputs("</ul>\n</li>\n", fp);
+}
+
+static void
+make_files_page(const struct repo *r, const char *files)
+{
+	struct tree_node *root;
+	char *dir, *html;
+	FILE *fp;
+
+	root = tree_root_from_files(files);
+	dir = join3(r->outdir, "/", "file");
+	if (dir == NULL)
+	    err(1, "malloc");
+	ensure_dir(dir);
+	free(dir);
+
+	html = join3(r->outdir, "/", "files.html");
+	if (html == NULL)
+	    err(1, "malloc");
+	fp = fopen(html, "w");
+	if (fp == NULL)
+	    err(1, "fopen %s", html);
+
+	fputs("<!doctype html>\n<html lang=\"en\">\n<head>\n", fp);
+	fputs("<meta charset=\"utf-8\">\n", fp);
+	fputs("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n", fp);
+	fputs("<link rel=\"stylesheet\" href=\"../style.css\">\n", fp);
+	fputs("<title>", fp);
+	write_html_escaped(fp, r->name);
+	fputs(" files</title>\n", fp);
+	fputs("</head>\n<body>\n", fp);
+	if (strcmp(r->rootlink_repo, "index.html") != 0)
+	    fputs("<p><a class=\"index-link\" href=\"../index.html\">index</a></p>\n", fp);
+	fputs("<h1>", fp);
+	write_html_escaped(fp, r->name);
+	fputs("</h1>\n<p><a href=\"index.html\">summary</a> | <a href=\"files.html\">files</a>", fp);
+	fputs(" | <a href=\"history.html\">history</a></p>\n<h2>Files</h2>\n", fp);
+	render_tree(fp, r, root, 0);
+	fputs("</body>\n</html>\n", fp);
+
+	if (fclose(fp) == EOF)
+	    err(1, "fclose %s", html);
+	free(html);
+	tree_free(root);
+}
+
+static void
+make_repo_page(const struct repo *r)
+{
+	char *commits, *files, *full_hash, *history, *line, *qpath, *readme;
+	char *short_hash, *author, *date, *subject;
+	FILE *fp;
+
+	qpath = write_shell_quoted(r->path);
+	if (qpath == NULL)
+	    err(1, "malloc");
+	commits = read_cmd_output("git -C %s log -n %d --date=short "
+	    "--pretty=format:%%H%%x1f%%h%%x1f%%ad%%x1f%%an%%x1f%%s 2>/dev/null",
+	    qpath, COMMIT_LIMIT);
+	if (commits == NULL)
+	    errx(1, "failed to read commits for %s", r->path);
+	files = read_cmd_output("git -C %s ls-tree -r --name-only HEAD 2>/dev/null",
+	    qpath);
+	if (files == NULL)
+	    files = str_dup("");
+	if (files == NULL)
+	    err(1, "malloc");
+	free(qpath);
+
+	readme = read_readme(r);
+	if (readme == NULL)
+	    err(1, "malloc");
+
+	make_readme_page(r, readme);
+	make_files_page(r, files);
+
+	history = join3(r->outdir, "/", "history.html");
+	if (history == NULL)
+	    err(1, "malloc");
+	fp = fopen(history, "w");
+	if (fp == NULL)
+	    err(1, "fopen %s", history);
+
+	fputs("<!doctype html>\n<html lang=\"en\">\n<head>\n", fp);
+	fputs("<meta charset=\"utf-8\">\n", fp);
+	fputs("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n", fp);
+	fputs("<link rel=\"stylesheet\" href=\"../style.css\">\n", fp);
+	fputs("<title>", fp);
+	write_html_escaped(fp, r->name);
+	fputs(" history</title>\n</head>\n<body>\n", fp);
+	if (strcmp(r->rootlink_repo, "index.html") != 0)
+	    fputs("<p><a class=\"index-link\" href=\"../index.html\">index</a></p>\n", fp);
+	fputs("<h1>", fp);
+	write_html_escaped(fp, r->name);
+	fputs("</h1>\n<p><a href=\"index.html\">summary</a> | <a href=\"files.html\">files</a>", fp);
+	fputs(" | <a href=\"history.html\">history</a></p>\n", fp);
+	fputs("<h2>History</h2>\n", fp);
+	fputs("<table>\n<thead><tr><th>hash</th><th>date</th><th>author</th><th>subject</th></tr></thead>\n<tbody>\n", fp);
+
+	line = strtok(commits, "\n");
+	while (line != NULL) {
+	    full_hash = split_field(&line);
+	    short_hash = split_field(&line);
+	    date = split_field(&line);
+	    author = split_field(&line);
+	    subject = line == NULL ? "" : line;
+	    if (full_hash != NULL && short_hash != NULL && date != NULL &&
+	        author != NULL && subject != NULL) {
+	        make_commit_page(r, full_hash, short_hash, date, author,
+	            subject);
+	        fputs("<tr><td><a href=\"commits/", fp);
+	        write_html_escaped(fp, full_hash);
+	        fputs(".html\"><code>", fp);
+	        write_html_escaped(fp, short_hash);
+	        fputs("</code></a></td><td>", fp);
+	        write_html_escaped(fp, date);
+	        fputs("</td><td>", fp);
+	        write_html_escaped(fp, author);
+	        fputs("</td><td>", fp);
+	        write_html_escaped(fp, subject);
+	        fputs("</td></tr>\n", fp);
+	    }
+	    line = strtok(NULL, "\n");
+	}
+	fputs("</tbody>\n</table>\n</body>\n</html>\n", fp);
+
+	if (fclose(fp) == EOF)
+	    err(1, "fclose %s", history);
+	write_cache_head(r->outdir, r->head);
+
+	free(history);
+	free(commits);
+	free(files);
+	free(readme);
+}
+
+static void
+make_index(const char *outdir, struct repo *repos, size_t nrepos)
+{
+	FILE *fp;
+	char *path;
+	size_t i;
+
+	path = join3(outdir, "/", "index.html");
+	if (path == NULL)
+	    err(1, "malloc");
+	fp = fopen(path, "w");
+	if (fp == NULL)
+	    err(1, "fopen %s", path);
+
+	fputs("<!doctype html>\n<html lang=\"en\">\n<head>\n", fp);
+	fputs("<meta charset=\"utf-8\">\n", fp);
+	fputs("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n", fp);
+	fputs("<link rel=\"stylesheet\" href=\"style.css\">\n", fp);
+	fputs("<title>git index</title>\n", fp);
+	fputs("</head>\n<body>\n<h1>Repositories</h1>\n", fp);
+	fputs("<table>\n<thead><tr><th>repo</th><th>owner</th><th>branch</th><th>head</th><th>last commit</th></tr></thead>\n<tbody>\n", fp);
+	for (i = 0; i < nrepos; i++) {
+	    fputs("<tr><td><a href=\"", fp);
+	    write_html_escaped(fp, repos[i].name);
+	    fputs("/\">", fp);
+	    write_html_escaped(fp, repos[i].name);
+	    fputs("</a></td><td>", fp);
+	    if (repos[i].owner[0] == '\0')
+	        fputs("unknown", fp);
+	    else
+	        write_html_escaped(fp, repos[i].owner);
+	    fputs("</td><td>", fp);
+	    write_html_escaped(fp, repos[i].branch);
+	    fputs("</td><td><code>", fp);
+	    write_html_escaped(fp, repos[i].head);
+	    fputs("</code></td><td>", fp);
+	    write_html_escaped(fp, repos[i].last_date);
+	    fputs("</td></tr>\n", fp);
+	}
+	fputs("</tbody>\n</table>\n</body>\n</html>\n", fp);
+
+	if (fclose(fp) == EOF)
+	    err(1, "fclose %s", path);
+	free(path);
+}
+
+static void
+free_repos(struct repo *repos, size_t nrepos)
+{
+	size_t i;
+
+	for (i = 0; i < nrepos; i++) {
+	    free(repos[i].branch);
+	    free(repos[i].cloneurl);
+	    free(repos[i].head);
+	    free(repos[i].last_author);
+	    free(repos[i].last_date);
+	    free(repos[i].last_subject);
+	    free(repos[i].name);
+	    free(repos[i].owner);
+	    free(repos[i].outdir);
+	    free(repos[i].path);
+	    free(repos[i].rootlink_deep);
+	    free(repos[i].rootlink_repo);
+	}
+	free(repos);
+}