Repositories / gitweb2.git

gitweb2.git

Clone (read-only): git clone http://git.guha-anderson.com/git/gitweb2.git

Branch

Add native Git web viewer

Create a standalone Kotlin/Native web app that links against kotlin-git and serves read-only repository, branch, file, directory, and commit history pages.

Use libevent through cinterop for HTTP serving, scan worktree and bare repository layouts, skip invalid repositories, and show only local branches.
Author
Arjun Guha <a.guha@northeastern.edu>
Date
2026-04-19 06:43:13 -0400
Commit
d7d8ac1901971cad07e7bb0d97e70ccc33f163b3
.gitignore
new file mode 100644
index 0000000..f4ddb57
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+.build/
+build/
+*.kexe
+*.klib
+.kotlin/
Makefile
new file mode 100644
index 0000000..3b7d8db
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,58 @@
+KOTLIN_HOME ?= /home/arjun/.local/opt/kotlin
+KONANC := $(KOTLIN_HOME)/bin/konanc
+CINTEROP := $(KOTLIN_HOME)/bin/cinterop
+PKG_CONFIG ?= pkg-config
+TARGET ?= linux_x64
+
+KOTLIN_GIT_DIR ?= /home/arjun/repos/homebox/kotlin-git
+KOTLIN_GIT_KLIB := $(KOTLIN_GIT_DIR)/.build/klib/kotlin-git.klib
+LIBGIT2_KLIB := $(KOTLIN_GIT_DIR)/.build/klib/libgit2.klib
+
+BUILD_DIR := .build
+LIBEVENT_KLIB := $(BUILD_DIR)/klib/libevent.klib
+APP_BIN := $(BUILD_DIR)/bin/gitweb2.kexe
+
+LIBGIT2_LIB_DIR := $(shell $(PKG_CONFIG) --variable=libdir libgit2 2>/dev/null)
+LIBGIT2_LIBS := $(shell $(PKG_CONFIG) --libs libgit2 2>/dev/null)
+LIBEVENT_INCLUDE_DIR := $(shell $(PKG_CONFIG) --variable=includedir libevent 2>/dev/null)
+LIBEVENT_LIB_DIR := $(shell $(PKG_CONFIG) --variable=libdir libevent 2>/dev/null)
+LIBEVENT_CFLAGS := $(shell $(PKG_CONFIG) --cflags libevent 2>/dev/null)
+LIBEVENT_LIBS := $(shell $(PKG_CONFIG) --libs libevent 2>/dev/null)
+
+SOURCES := $(shell find src/main/kotlin -name '*.kt' | sort)
+
+.PHONY: all check-system-libevent check-system-libgit2 kotlin-git run clean
+
+all: $(APP_BIN)
+
+check-system-libgit2:
+	$(PKG_CONFIG) --exists libgit2
+
+check-system-libevent:
+	$(PKG_CONFIG) --exists libevent
+
+kotlin-git: | check-system-libgit2
+	$(MAKE) -C $(KOTLIN_GIT_DIR)
+
+$(LIBEVENT_KLIB): src/nativeInterop/cinterop/libevent.def | check-system-libevent
+	mkdir -p $(dir $@)
+	$(CINTEROP) -target $(TARGET) -def $< -o $(BUILD_DIR)/klib/libevent \
+		-libraryPath $(LIBEVENT_LIB_DIR) \
+		-compiler-option -I$(LIBEVENT_INCLUDE_DIR) \
+		$(foreach flag,$(LIBEVENT_CFLAGS),-compiler-option $(flag))
+
+$(APP_BIN): $(SOURCES) $(LIBEVENT_KLIB) kotlin-git
+	mkdir -p $(dir $@)
+	$(KONANC) -target $(TARGET) -o $(BUILD_DIR)/bin/gitweb2 \
+		-library $(LIBGIT2_KLIB) -library $(KOTLIN_GIT_KLIB) -library $(LIBEVENT_KLIB) $(SOURCES) \
+		-linker-option -L$(LIBGIT2_LIB_DIR) \
+		-linker-option -L$(LIBEVENT_LIB_DIR) \
+		-linker-option --allow-shlib-undefined \
+		$(foreach flag,$(LIBGIT2_LIBS),-linker-option $(flag)) \
+		$(foreach flag,$(LIBEVENT_LIBS),-linker-option $(flag))
+
+run: $(APP_BIN)
+	$(APP_BIN) ~/repos
+
+clean:
+	rm -rf $(BUILD_DIR)
README.md
new file mode 100644
index 0000000..545849c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,40 @@
+# gitweb2
+
+A Kotlin/Native, read-only web viewer for local Git repositories.
+
+The app links against the `kotlin-git` library in
+`/home/arjun/repos/homebox/kotlin-git` and uses libevent's native HTTP server
+through cinterop. It does not use the JVM.
+
+## Requirements
+
+- Kotlin/Native installed at `/home/arjun/.local/opt/kotlin`
+- system `libgit2` and `libevent` visible through `pkg-config`
+- `make`
+
+## Build
+
+```sh
+make
+```
+
+## Run
+
+```sh
+.build/bin/gitweb2.kexe ~/repos
+```
+
+Options:
+
+```sh
+.build/bin/gitweb2.kexe ~/repos --host 127.0.0.1 --port 8080
+```
+
+The server scans nested directories for Git repositories and renders
+repository, branch, file, directory, and commit history pages. Nested repository
+paths and branch names that contain slashes are percent-encoded in URLs:
+
+```text
+/repo/homebox%2Fkotlin-git/main/README.md
+/repo/homebox%2Fkotlin-git/main/-/commits
+```
src/main/kotlin/AppMain.kt
new file mode 100644
index 0000000..82da7eb
--- /dev/null
+++ b/src/main/kotlin/AppMain.kt
@@ -0,0 +1,3 @@
+fun main(args: Array<String>) {
+    gitweb.runGitWeb2(args)
+}
src/main/kotlin/Main.kt
new file mode 100644
index 0000000..252f5fe
--- /dev/null
+++ b/src/main/kotlin/Main.kt
@@ -0,0 +1,480 @@
+@file:OptIn(ExperimentalForeignApi::class)
+
+package gitweb
+
+import gitweb.internal.event.evbuffer_add
+import gitweb.internal.event.evbuffer_free
+import gitweb.internal.event.evbuffer_new
+import gitweb.internal.event.event_base_dispatch
+import gitweb.internal.event.event_base_free
+import gitweb.internal.event.event_base_new
+import gitweb.internal.event.evhttp_add_header
+import gitweb.internal.event.evhttp_bind_socket
+import gitweb.internal.event.evhttp_free
+import gitweb.internal.event.evhttp_new
+import gitweb.internal.event.evhttp_request_get_output_headers
+import gitweb.internal.event.evhttp_request_get_uri
+import gitweb.internal.event.evhttp_send_reply
+import gitweb.internal.event.evhttp_set_gencb
+import kotlinx.cinterop.COpaquePointer
+import kotlinx.cinterop.CPointer
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.StableRef
+import kotlinx.cinterop.addressOf
+import kotlinx.cinterop.asStableRef
+import kotlinx.cinterop.convert
+import kotlinx.cinterop.pointed
+import kotlinx.cinterop.staticCFunction
+import kotlinx.cinterop.toKString
+import kotlinx.cinterop.usePinned
+import kotlinx.git.Blob
+import kotlinx.git.Branch
+import kotlinx.git.BranchType
+import kotlinx.git.Git
+import kotlinx.git.GitException
+import kotlinx.git.Repository
+import kotlinx.git.TreeEntryKind
+import platform.posix.F_OK
+import platform.posix.access
+import platform.posix.closedir
+import platform.posix.errno
+import platform.posix.getenv
+import platform.posix.opendir
+import platform.posix.readdir
+import platform.posix.strerror
+import kotlin.system.exitProcess
+
+private const val DefaultHost = "127.0.0.1"
+private const val DefaultPort = 8080
+private const val MaxScanDepth = 5
+
+fun runGitWeb2(args: Array<String>) {
+    val config = parseArgs(args) ?: return
+    GitWebServer(config.root, config.host, config.port).start()
+}
+
+private data class Config(val root: String, val host: String, val port: Int)
+
+private data class RepoInfo(val key: String, val name: String, val path: String)
+
+private data class Response(val status: Int, val body: String)
+
+private class GitWebServer(
+    private val root: String,
+    private val host: String,
+    private val port: Int,
+) {
+    private val repos: List<RepoInfo> = discoverRepos(root)
+    private val reposByKey: Map<String, RepoInfo> = repos.associateBy { it.key }
+
+    fun start() {
+        val base = event_base_new() ?: error("event_base_new failed")
+        val http = evhttp_new(base) ?: error("evhttp_new failed")
+        val self = StableRef.create(this)
+        try {
+            evhttp_set_gencb(http, staticCFunction(::handleRequest), self.asCPointer())
+            if (evhttp_bind_socket(http, host, port.toUShort()) != 0) {
+                error("failed to bind http server to $host:$port: ${posixError()}")
+            }
+            println("gitweb2 serving ${repos.size} repositories from $root at http://$host:$port/")
+            event_base_dispatch(base)
+        } finally {
+            self.dispose()
+            evhttp_free(http)
+            event_base_free(base)
+            Git.shutdown()
+        }
+    }
+
+    fun route(rawUri: String): Response {
+        val rawPath = rawUri.substringBefore('?')
+        val parts = rawPath.split('/').filter { it.isNotEmpty() }
+        return try {
+            when {
+                parts.isEmpty() -> index()
+                parts.size >= 2 && parts[0] == "repo" -> repo(parts.drop(1))
+                else -> notFound("No route for ${html(rawPath)}")
+            }
+        } catch (error: GitException) {
+            Response(404, page("Not found", "<p class=\"notice\">${html(error.message ?: "Git error")}</p>"))
+        } catch (error: Throwable) {
+            Response(500, page("Server error", "<p class=\"notice\">${html(error.message ?: "Unexpected error")}</p>"))
+        }
+    }
+
+    private fun index(): Response {
+        val body = buildString {
+            append("<section class=\"hero\"><p class=\"eyebrow\">${html(root)}</p><h1>Repositories</h1></section>")
+            if (repos.isEmpty()) {
+                append("<p class=\"notice\">No Git repositories were found under this path.</p>")
+                return@buildString
+            }
+            append("<ol class=\"repo-list\">")
+            for (repo in repos) {
+                val summary = repoSummary(repo)
+                append("<li><a href=\"${repoUrl(repo, summary.branch)}\"><strong>${html(repo.name)}</strong>")
+                append("<span>${html(summary.branch)}")
+                if (summary.shortId.isNotEmpty()) append(" · ${html(summary.shortId)}")
+                append("</span><small>${html(summary.summary)}</small></a></li>")
+            }
+            append("</ol>")
+        }
+        return Response(200, page("Repositories", body))
+    }
+
+    private fun repoSummary(repo: RepoInfo): RepoSummary =
+        runCatching {
+            openRepo(repo) { git ->
+                val branches = git.localBranches()
+                val head = branches.firstOrNull { it.isHead }?.name ?: branches.firstOrNull()?.name ?: "main"
+                val commit = runCatching { git.branchCommit(head) }.getOrNull()
+                RepoSummary(head, commit?.summary ?: "No commits", commit?.id?.value?.take(12) ?: "")
+            }
+        }.getOrElse {
+            RepoSummary("main", "Unavailable", "")
+        }
+
+    private fun repo(parts: List<String>): Response {
+        val repoKey = urlDecode(parts[0])
+        val repo = reposByKey[repoKey] ?: return notFound("Unknown repository ${html(repoKey)}")
+        if (parts.size == 1) {
+            val branch = openRepo(repo) { git ->
+                val branches = git.localBranches()
+                branches.firstOrNull { it.isHead }?.name ?: branches.firstOrNull()?.name ?: "main"
+            }
+            return Response(200, repoPage(repo, branch, ""))
+        }
+        val branch = urlDecode(parts[1])
+        val pathParts = parts.drop(2)
+        if (pathParts.size == 2 && pathParts[0] == "-" && pathParts[1] == "commits") {
+            return Response(200, commitsPage(repo, branch))
+        }
+        val path = pathParts.joinToString("/") { urlDecode(it) }
+        return Response(200, repoPage(repo, branch, path))
+    }
+
+    private fun repoPage(repo: RepoInfo, branch: String, path: String): String = openRepo(repo) { git ->
+        val branches = git.localBranches()
+        val commit = git.branchCommit(branch)
+        val entry = if (path.isBlank()) null else runCatching { git.treeEntry(path, commit) }.getOrNull()
+        val content = if (entry == null || entry.kind == TreeEntryKind.Tree) {
+            treeView(repo, git, branch, path, branches)
+        } else {
+            blobView(repo, git.blob(path, commit), branch, path, branches)
+        }
+        page(repo.name, content)
+    }
+
+    private fun treeView(
+        repo: RepoInfo,
+        git: Repository,
+        branch: String,
+        path: String,
+        branches: List<Branch>,
+    ): String {
+        val listing = git.tree(path, git.branchCommit(branch))
+        return buildString {
+            append(repoHeader(repo, branch, path, branches, "code"))
+            append("<div class=\"panel\"><table><tbody>")
+            val parent = path.substringBeforeLast('/', "")
+            if (path.isNotBlank()) {
+                append("<tr><td><a href=\"${repoUrl(repo, branch, parent)}\">..</a></td><td class=\"kind\">tree</td></tr>")
+            }
+            for (entry in listing.entries) {
+                val child = joinPath(path, entry.name)
+                append("<tr><td><a href=\"${repoUrl(repo, branch, child)}\">${html(entry.name)}</a></td>")
+                append("<td class=\"kind\">${entry.kind.name.lowercase()}</td></tr>")
+            }
+            append("</tbody></table></div>")
+        }
+    }
+
+    private fun blobView(
+        repo: RepoInfo,
+        blob: Blob,
+        branch: String,
+        path: String,
+        branches: List<Branch>,
+    ): String = buildString {
+        append(repoHeader(repo, branch, path, branches, "code"))
+        append("<div class=\"panel file\">")
+        append("<div class=\"file-meta\">${blob.bytes.size} bytes · ${html(blob.id.value.take(12))}</div>")
+        if (blob.isBinary) {
+            append("<p class=\"notice\">Binary file</p>")
+        } else {
+            append("<pre><code>${html(blob.text())}</code></pre>")
+        }
+        append("</div>")
+    }
+
+    private fun commitsPage(repo: RepoInfo, branch: String): String = openRepo(repo) { git ->
+        buildString {
+            append(repoHeader(repo, branch, "", git.localBranches(), "history"))
+            append("<ol class=\"commits\">")
+            for (commit in git.commits(branch, 100)) {
+                append("<li><strong>${html(commit.summary)}</strong>")
+                append("<span>${html(commit.id.value.take(12))} · ${html(commit.author.name)}</span>")
+                append("</li>")
+            }
+            append("</ol>")
+        }.let { page("$branch history", it) }
+    }
+
+    private fun repoHeader(
+        repo: RepoInfo,
+        branch: String,
+        path: String,
+        branches: List<Branch>,
+        selected: String,
+    ): String = buildString {
+        append("<section class=\"repo-head\"><p><a href=\"/\">Repositories</a> / ${html(repo.name)}</p>")
+        append("<h1>${html(if (path.isBlank()) repo.name else path)}</h1>")
+        append("<div class=\"toolbar\"><span>Branch</span><nav>")
+        for (item in branches) {
+            val active = if (item.name == branch) " class=\"active\"" else ""
+            append("<a$active href=\"${repoUrl(repo, item.name, path)}\">${html(item.name)}</a>")
+        }
+        append("</nav></div><div class=\"tabs\">")
+        tab("code", "Code", repoUrl(repo, branch, path), selected)
+        tab("history", "History", "/repo/${urlEncode(repo.key)}/${urlEncode(branch)}/-/commits", selected)
+        append("</div></section>")
+    }
+
+    private fun StringBuilder.tab(id: String, label: String, href: String, selected: String) {
+        val active = if (id == selected) " class=\"active\"" else ""
+        append("<a$active href=\"$href\">$label</a>")
+    }
+
+    private fun <T> openRepo(repo: RepoInfo, block: (Repository) -> T): T =
+        Git.open(repo.path).use(block)
+
+    private fun notFound(message: String): Response =
+        Response(404, page("Not found", "<p class=\"notice\">$message</p>"))
+
+    private data class RepoSummary(val branch: String, val summary: String, val shortId: String)
+}
+
+private fun handleRequest(request: CPointer<cnames.structs.evhttp_request>?, context: COpaquePointer?) {
+    if (request == null || context == null) return
+    val server = context.asStableRef<GitWebServer>().get()
+    val uri = evhttp_request_get_uri(request)?.toKString() ?: "/"
+    send(request, server.route(uri))
+}
+
+private fun send(request: CPointer<cnames.structs.evhttp_request>, response: Response) {
+    val bytes = response.body.encodeToByteArray()
+    val buffer = evbuffer_new() ?: return
+    try {
+        bytes.usePinned { pinned ->
+            evbuffer_add(buffer, pinned.addressOf(0), bytes.size.convert())
+        }
+        val headers = evhttp_request_get_output_headers(request)
+        evhttp_add_header(headers, "Content-Type", "text/html; charset=utf-8")
+        evhttp_add_header(headers, "Cache-Control", "no-store")
+        evhttp_send_reply(request, response.status, statusText(response.status), buffer)
+    } finally {
+        evbuffer_free(buffer)
+    }
+}
+
+private fun page(title: String, body: String): String = """
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>${html(title)} · gitweb2</title>
+<style>
+:root { color-scheme: light; --ink: #1f2428; --muted: #586069; --line: #d0d7de; --wash: #f6f8fa; --link: #0969da; --accent: #1a7f37; }
+* { box-sizing: border-box; }
+body { margin: 0; font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--ink); background: #fff; }
+a { color: var(--link); text-decoration: none; }
+a:hover { text-decoration: underline; }
+main { max-width: 1180px; margin: 0 auto; padding: 28px 20px 56px; }
+.hero, .repo-head { border-bottom: 1px solid var(--line); margin-bottom: 20px; padding-bottom: 16px; }
+.eyebrow, .repo-head p, .file-meta, .kind, small, .commits span, .repo-list span { color: var(--muted); }
+h1 { margin: 4px 0 8px; font-size: 28px; line-height: 1.2; overflow-wrap: anywhere; }
+.repo-list, .commits { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
+.repo-list li, .commits li { border-top: 1px solid var(--line); }
+.repo-list li:first-child, .commits li:first-child { border-top: 0; }
+.repo-list a, .commits li { display: grid; gap: 3px; padding: 14px 16px; }
+.repo-list strong, .commits strong { font-size: 16px; overflow-wrap: anywhere; }
+.repo-list small, .commits span { display: block; overflow-wrap: anywhere; }
+.panel { border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
+table { width: 100%; border-collapse: collapse; }
+td { padding: 10px 12px; border-top: 1px solid var(--line); vertical-align: top; overflow-wrap: anywhere; }
+tr:first-child td { border-top: 0; }
+.kind { width: 120px; text-align: right; }
+.toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 12px 0; }
+.toolbar nav, .tabs { display: flex; gap: 8px; flex-wrap: wrap; }
+.toolbar a, .tabs a { border: 1px solid var(--line); border-radius: 8px; padding: 5px 9px; color: var(--ink); background: #fff; }
+.toolbar a.active, .tabs a.active { border-color: var(--accent); color: var(--accent); font-weight: 600; }
+.file pre { margin: 0; padding: 16px; overflow: auto; background: var(--wash); }
+.file code { font: 13px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre; }
+.file-meta { padding: 10px 12px; border-bottom: 1px solid var(--line); background: #fff; }
+.notice { padding: 16px; border: 1px solid var(--line); border-radius: 8px; background: var(--wash); }
+@media (max-width: 640px) {
+  main { padding: 20px 12px 40px; }
+  h1 { font-size: 23px; }
+  .kind { width: 78px; }
+}
+</style>
+</head>
+<body><main>$body</main></body>
+</html>
+""".trimIndent()
+
+private fun discoverRepos(root: String): List<RepoInfo> {
+    val found = mutableListOf<RepoInfo>()
+    fun scan(path: String, relative: String, depth: Int) {
+        if (isGitRepo(path)) {
+            if (isOpenableGitRepo(path)) {
+                val key = relative.ifBlank { path.trimEnd('/').substringAfterLast('/') }
+                found += RepoInfo(key = key, name = key, path = path)
+            }
+            return
+        }
+        if (depth >= MaxScanDepth) return
+        val dir = opendir(path) ?: return
+        try {
+            while (true) {
+                val entry = readdir(dir) ?: break
+                val name = entry.pointed.d_name.toKString()
+                if (name == "." || name == ".." || name == ".git") continue
+                val child = joinPath(path, name)
+                val childRelative = if (relative.isBlank()) name else "$relative/$name"
+                scan(child, childRelative, depth + 1)
+            }
+        } finally {
+            closedir(dir)
+        }
+    }
+    scan(expandHome(root).trimEnd('/'), "", 0)
+    return found.sortedBy { it.name }
+}
+
+private fun Repository.localBranches(): List<Branch> =
+    branches().filter { it.type == BranchType.Local }
+
+private fun isGitRepo(path: String): Boolean =
+    access(joinPath(path, ".git"), F_OK) == 0 ||
+        access(joinPath(path, "HEAD"), F_OK) == 0 &&
+        access(joinPath(path, "objects"), F_OK) == 0 &&
+        access(joinPath(path, "refs"), F_OK) == 0
+
+private fun isOpenableGitRepo(path: String): Boolean =
+    runCatching { Git.open(path).use { } }.isSuccess
+
+private fun repoUrl(repo: RepoInfo, branch: String, path: String = ""): String {
+    val prefix = "/repo/${urlEncode(repo.key)}/${urlEncode(branch)}"
+    return if (path.isBlank()) prefix else "$prefix/${path.split('/').joinToString("/") { urlEncode(it) }}"
+}
+
+private fun joinPath(base: String, child: String): String =
+    when {
+        base.isBlank() -> child
+        child.isBlank() -> base
+        base.endsWith("/") -> base + child
+        else -> "$base/$child"
+    }
+
+private fun html(value: String): String = buildString(value.length) {
+    for (char in value) {
+        when (char) {
+            '&' -> append("&amp;")
+            '<' -> append("&lt;")
+            '>' -> append("&gt;")
+            '"' -> append("&quot;")
+            '\'' -> append("&#39;")
+            else -> append(char)
+        }
+    }
+}
+
+private fun urlEncode(value: String): String {
+    val bytes = value.encodeToByteArray()
+    val hex = "0123456789ABCDEF"
+    return buildString {
+        for (byte in bytes) {
+            val int = byte.toInt() and 0xff
+            val char = int.toChar()
+            if (char in 'A'..'Z' || char in 'a'..'z' || char in '0'..'9' || char == '-' || char == '_' || char == '.' || char == '~') {
+                append(char)
+            } else {
+                append('%')
+                append(hex[int shr 4])
+                append(hex[int and 15])
+            }
+        }
+    }
+}
+
+private fun urlDecode(value: String): String {
+    val bytes = mutableListOf<Byte>()
+    var index = 0
+    while (index < value.length) {
+        val char = value[index]
+        if (char == '%' && index + 2 < value.length) {
+            val hex = value.substring(index + 1, index + 3).toIntOrNull(16)
+            if (hex != null) {
+                bytes += hex.toByte()
+                index += 3
+                continue
+            }
+        }
+        bytes += if (char == '+') ' '.code.toByte() else char.code.toByte()
+        index++
+    }
+    return bytes.toByteArray().decodeToString()
+}
+
+private fun expandHome(path: String): String {
+    if (path == "~") return getenv("HOME")?.toKString() ?: path
+    if (path.startsWith("~/")) return "${getenv("HOME")?.toKString() ?: ""}/${path.drop(2)}"
+    return path
+}
+
+private fun statusText(status: Int): String = when (status) {
+    200 -> "OK"
+    404 -> "Not Found"
+    500 -> "Server Error"
+    else -> "OK"
+}
+
+private fun posixError(): String {
+    val code = errno
+    val message = strerror(code)?.toKString()
+    return if (message.isNullOrBlank()) "errno $code" else "$message (errno $code)"
+}
+
+private fun parseArgs(args: Array<String>): Config? {
+    if (args.isEmpty() || args[0] == "-h" || args[0] == "--help") {
+        println("usage: gitweb2 <repo-root> [--host 127.0.0.1] [--port 8080]")
+        return null
+    }
+    var host = DefaultHost
+    var port = DefaultPort
+    var index = 1
+    while (index < args.size) {
+        when (args[index]) {
+            "--host" -> host = args.getOrNull(++index) ?: die("missing --host value")
+            "--port" -> port = args.getOrNull(++index)?.toIntOrNull() ?: die("missing or invalid --port value")
+            else -> die("unknown argument ${args[index]}")
+        }
+        index++
+    }
+    return Config(root = args[0], host = host, port = port)
+}
+
+private fun die(message: String): Nothing {
+    println(message)
+    exitProcess(2)
+}
+
+private inline fun <T : AutoCloseable, R> T.use(block: (T) -> R): R {
+    try {
+        return block(this)
+    } finally {
+        close()
+    }
+}
src/nativeInterop/cinterop/libevent.def
new file mode 100644
index 0000000..c558fb1
--- /dev/null
+++ b/src/nativeInterop/cinterop/libevent.def
@@ -0,0 +1,3 @@
+package = gitweb.internal.event
+headers = event2/event.h event2/http.h event2/buffer.h event2/keyvalq_struct.h
+linkerOpts.linux = -levent