Repositories / gitweb2.git

gitweb2.git

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

Branch

Convert web server to OCaml cohttp-eio

Author
Arjun Guha <a.guha@northeastern.edu>
Date
2026-04-30 04:49:37 -0400
Commit
b649bea6ca7244a28302adfec0349293acd5298a
Makefile
new file mode 100644
index 0000000..2f2cd3c
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,13 @@
+.PHONY: all build test clean
+
+all: build
+
+build:
+	dune build
+
+test:
+	dune test
+
+clean:
+	dune clean
+	rm -rf .build
README.md
index 2d8ea96..c19eade 100644
--- a/README.md
+++ b/README.md
@@ -1,34 +1,37 @@
 # gitweb2
 
-A Kotlin/Native, read-only web viewer for local Git repositories.
+An OCaml, read-only web viewer for local Git repositories.
 
-The app uses Ktor Native with the CIO engine for HTTP and the local
-`/media/external0/arjun/repos/homebox/kotlin-git` library for repository reads.
+The app uses `cohttp-eio` for HTTP and the local `ocaml-git` OCaml package for
+repository reads.
 
 ## Requirements
 
-- Kotlin/Native through the Gradle wrapper
-- system `libgit2` visible through `pkg-config`
-- the local `kotlin-git` klibs built at
-  `/media/external0/arjun/repos/homebox/kotlin-git/.build/klib`
-- the included Gradle wrapper
+- OCaml 5.4 or newer
+- opam
+- dune
+- system `git`
+- the local `ocaml-git` package installed or pinned
 
 ## Build
 
+From this repository, after installing or pinning `ocaml-git`:
+
 ```sh
-./gradlew build
+opam install . --deps-only --with-test
+dune build
 ```
 
 ## Run
 
 ```sh
-./build/bin/native/releaseExecutable/gitweb2.kexe ~/repos
+dune exec gitweb2 -- ~/repos
 ```
 
 Options:
 
 ```sh
-./build/bin/native/releaseExecutable/gitweb2.kexe ~/repos --host 127.0.0.1 --port 8080 --pygments "uv run --with pygments pygmentize"
+dune exec gitweb2 -- ~/repos --host 127.0.0.1 --port 8080 --pygments "uv run --with pygments pygmentize"
 ```
 
 Press `Ctrl+C` to stop the server.
@@ -38,6 +41,6 @@ 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
+/repo/homebox%2Focaml-git/main/README.md
+/repo/homebox%2Focaml-git/main/-/commits
 ```
build.gradle.kts
deleted file mode 100644
index ef42c45..0000000
--- a/build.gradle.kts
+++ /dev/null
@@ -1,68 +0,0 @@
-plugins {
-    kotlin("multiplatform") version "2.3.20"
-}
-
-group = "gitweb"
-version = "0.1.0"
-
-val ktorVersion = "3.4.2"
-val okioVersion = "3.17.0"
-val processVersion = "0.5.0"
-val kotlinGitDir = providers.gradleProperty("kotlinGitDir")
-    .orElse("/media/external0/arjun/repos/homebox/kotlin-git")
-val kotlinGitKlib = kotlinGitDir.map { file("$it/.build/klib/kotlin-git.klib") }
-val libgit2Klib = kotlinGitDir.map { file("$it/.build/klib/libgit2.klib") }
-
-fun pkgConfig(vararg args: String): List<String> =
-    providers.exec {
-        commandLine("pkg-config", *args)
-    }.standardOutput.asText.get().trim().split(Regex("\\s+")).filter { it.isNotBlank() }
-
-fun pkgConfigValue(vararg args: String): String =
-    providers.exec {
-        commandLine("pkg-config", *args)
-    }.standardOutput.asText.get().trim()
-
-kotlin {
-    val hostOs = System.getProperty("os.name")
-    val arch = System.getProperty("os.arch")
-    val nativeTarget = when {
-        hostOs == "Linux" && (arch == "x86_64" || arch == "amd64") -> linuxX64("native")
-        hostOs == "Linux" && arch == "aarch64" -> linuxArm64("native")
-        else -> throw GradleException("Unsupported Kotlin/Native host: $hostOs $arch")
-    }
-
-    nativeTarget.apply {
-        binaries {
-            all {
-                linkerOpts("-L${pkgConfigValue("--variable=libdir", "libgit2")}")
-                linkerOpts(pkgConfig("--libs", "libgit2"))
-                linkerOpts("-lutil")
-            }
-            executable {
-                baseName = "gitweb2"
-                entryPoint = "main"
-            }
-        }
-    }
-
-    sourceSets {
-        val nativeMain by getting {
-            kotlin.srcDir("src/main/kotlin")
-            dependencies {
-                implementation("io.ktor:ktor-server-core:$ktorVersion")
-                implementation("io.ktor:ktor-server-cio:$ktorVersion")
-                implementation("com.squareup.okio:okio:$okioVersion")
-                implementation("io.matthewnelson.kmp-process:process:$processVersion")
-                implementation(files(kotlinGitKlib))
-                implementation(files(libgit2Klib))
-            }
-        }
-        val nativeTest by getting {
-            dependencies {
-                implementation(kotlin("test"))
-                implementation("io.ktor:ktor-server-test-host:$ktorVersion")
-            }
-        }
-    }
-}
dune-project
new file mode 100644
index 0000000..dd3c04c
--- /dev/null
+++ b/dune-project
@@ -0,0 +1,15 @@
+(lang dune 3.22)
+(name gitweb2)
+(generate_opam_files true)
+
+(package
+ (name gitweb2)
+ (synopsis "Read-only local Git web viewer")
+ (depends
+  (ocaml (>= 5.4))
+  dune
+  ocaml-git
+  cohttp-eio
+  eio_main
+  uri
+  alcotest))
gitweb2.opam
new file mode 100644
index 0000000..1d3fcf8
--- /dev/null
+++ b/gitweb2.opam
@@ -0,0 +1,28 @@
+# This file is generated by dune, edit dune-project instead
+opam-version: "2.0"
+synopsis: "Read-only local Git web viewer"
+depends: [
+  "ocaml" {>= "5.4"}
+  "dune" {>= "3.22"}
+  "ocaml-git"
+  "cohttp-eio"
+  "eio_main"
+  "uri"
+  "alcotest"
+  "odoc" {with-doc}
+]
+build: [
+  ["dune" "subst"] {dev}
+  [
+    "dune"
+    "build"
+    "-p"
+    name
+    "-j"
+    jobs
+    "@install"
+    "@runtest" {with-test}
+    "@doc" {with-doc}
+  ]
+]
+x-maintenance-intent: ["(latest)"]
gradle.properties
deleted file mode 100644
index eb97df2..0000000
--- a/gradle.properties
+++ /dev/null
@@ -1,3 +0,0 @@
-org.gradle.parallel=true
-kotlin.code.style=official
-kotlin.mpp.applyDefaultHierarchyTemplate=false
gradle/wrapper/gradle-wrapper.jar
deleted file mode 100644
index d997cfc..0000000
Binary files a/gradle/wrapper/gradle-wrapper.jar and /dev/null differ
gradle/wrapper/gradle-wrapper.properties
deleted file mode 100644
index c61a118..0000000
--- a/gradle/wrapper/gradle-wrapper.properties
+++ /dev/null
@@ -1,7 +0,0 @@
-distributionBase=GRADLE_USER_HOME
-distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
-networkTimeout=10000
-validateDistributionUrl=true
-zipStoreBase=GRADLE_USER_HOME
-zipStorePath=wrapper/dists
gradlew
deleted file mode 100755
index 739907d..0000000
--- a/gradlew
+++ /dev/null
@@ -1,248 +0,0 @@
-#!/bin/sh
-
-#
-# Copyright © 2015 the original authors.
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-#      https://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-#
-# SPDX-License-Identifier: Apache-2.0
-#
-
-##############################################################################
-#
-#   Gradle start up script for POSIX generated by Gradle.
-#
-#   Important for running:
-#
-#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
-#       noncompliant, but you have some other compliant shell such as ksh or
-#       bash, then to run this script, type that shell name before the whole
-#       command line, like:
-#
-#           ksh Gradle
-#
-#       Busybox and similar reduced shells will NOT work, because this script
-#       requires all of these POSIX shell features:
-#         * functions;
-#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
-#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;
-#         * compound commands having a testable exit status, especially «case»;
-#         * various built-in commands including «command», «set», and «ulimit».
-#
-#   Important for patching:
-#
-#   (2) This script targets any POSIX shell, so it avoids extensions provided
-#       by Bash, Ksh, etc; in particular arrays are avoided.
-#
-#       The "traditional" practice of packing multiple parameters into a
-#       space-separated string is a well documented source of bugs and security
-#       problems, so this is (mostly) avoided, by progressively accumulating
-#       options in "$@", and eventually passing that to Java.
-#
-#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
-#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
-#       see the in-line comments for details.
-#
-#       There are tweaks for specific operating systems such as AIX, CygWin,
-#       Darwin, MinGW, and NonStop.
-#
-#   (3) This script is generated from the Groovy template
-#       https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
-#       within the Gradle project.
-#
-#       You can find Gradle at https://github.com/gradle/gradle/.
-#
-##############################################################################
-
-# Attempt to set APP_HOME
-
-# Resolve links: $0 may be a link
-app_path=$0
-
-# Need this for daisy-chained symlinks.
-while
-    APP_HOME=${app_path%"${app_path##*/}"}  # leaves a trailing /; empty if no leading path
-    [ -h "$app_path" ]
-do
-    ls=$( ls -ld "$app_path" )
-    link=${ls#*' -> '}
-    case $link in             #(
-      /*)   app_path=$link ;; #(
-      *)    app_path=$APP_HOME$link ;;
-    esac
-done
-
-# This is normally unused
-# shellcheck disable=SC2034
-APP_BASE_NAME=${0##*/}
-# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
-APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
-
-# Use the maximum available, or set MAX_FD != -1 to use that value.
-MAX_FD=maximum
-
-warn () {
-    echo "$*"
-} >&2
-
-die () {
-    echo
-    echo "$*"
-    echo
-    exit 1
-} >&2
-
-# OS specific support (must be 'true' or 'false').
-cygwin=false
-msys=false
-darwin=false
-nonstop=false
-case "$( uname )" in                #(
-  CYGWIN* )         cygwin=true  ;; #(
-  Darwin* )         darwin=true  ;; #(
-  MSYS* | MINGW* )  msys=true    ;; #(
-  NONSTOP* )        nonstop=true ;;
-esac
-
-
-
-# Determine the Java command to use to start the JVM.
-if [ -n "$JAVA_HOME" ] ; then
-    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
-        # IBM's JDK on AIX uses strange locations for the executables
-        JAVACMD=$JAVA_HOME/jre/sh/java
-    else
-        JAVACMD=$JAVA_HOME/bin/java
-    fi
-    if [ ! -x "$JAVACMD" ] ; then
-        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
-
-Please set the JAVA_HOME variable in your environment to match the
-location of your Java installation."
-    fi
-else
-    JAVACMD=java
-    if ! command -v java >/dev/null 2>&1
-    then
-        die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
-
-Please set the JAVA_HOME variable in your environment to match the
-location of your Java installation."
-    fi
-fi
-
-# Increase the maximum file descriptors if we can.
-if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
-    case $MAX_FD in #(
-      max*)
-        # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
-        # shellcheck disable=SC2039,SC3045
-        MAX_FD=$( ulimit -H -n ) ||
-            warn "Could not query maximum file descriptor limit"
-    esac
-    case $MAX_FD in  #(
-      '' | soft) :;; #(
-      *)
-        # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
-        # shellcheck disable=SC2039,SC3045
-        ulimit -n "$MAX_FD" ||
-            warn "Could not set maximum file descriptor limit to $MAX_FD"
-    esac
-fi
-
-# Collect all arguments for the java command, stacking in reverse order:
-#   * args from the command line
-#   * the main class name
-#   * -classpath
-#   * -D...appname settings
-#   * --module-path (only if needed)
-#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
-
-# For Cygwin or MSYS, switch paths to Windows format before running java
-if "$cygwin" || "$msys" ; then
-    APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
-
-    JAVACMD=$( cygpath --unix "$JAVACMD" )
-
-    # Now convert the arguments - kludge to limit ourselves to /bin/sh
-    for arg do
-        if
-            case $arg in                                #(
-              -*)   false ;;                            # don't mess with options #(
-              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath
-                    [ -e "$t" ] ;;                      #(
-              *)    false ;;
-            esac
-        then
-            arg=$( cygpath --path --ignore --mixed "$arg" )
-        fi
-        # Roll the args list around exactly as many times as the number of
-        # args, so each arg winds up back in the position where it started, but
-        # possibly modified.
-        #
-        # NB: a `for` loop captures its iteration list before it begins, so
-        # changing the positional parameters here affects neither the number of
-        # iterations, nor the values presented in `arg`.
-        shift                   # remove old arg
-        set -- "$@" "$arg"      # push replacement arg
-    done
-fi
-
-
-# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
-DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
-
-# Collect all arguments for the java command:
-#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
-#     and any embedded shellness will be escaped.
-#   * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
-#     treated as '${Hostname}' itself on the command line.
-
-set -- \
-        "-Dorg.gradle.appname=$APP_BASE_NAME" \
-        -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
-        "$@"
-
-# Stop when "xargs" is not available.
-if ! command -v xargs >/dev/null 2>&1
-then
-    die "xargs is not available"
-fi
-
-# Use "xargs" to parse quoted args.
-#
-# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
-#
-# In Bash we could simply go:
-#
-#   readarray ARGS < <( xargs -n1 <<<"$var" ) &&
-#   set -- "${ARGS[@]}" "$@"
-#
-# but POSIX shell has neither arrays nor command substitution, so instead we
-# post-process each arg (as a line of input to sed) to backslash-escape any
-# character that might be a shell metacharacter, then use eval to reverse
-# that process (while maintaining the separation between arguments), and wrap
-# the whole thing up as a single "set" statement.
-#
-# This will of course break if any of these variables contains a newline or
-# an unmatched quote.
-#
-
-eval "set -- $(
-        printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
-        xargs -n1 |
-        sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
-        tr '\n' ' '
-    )" '"$@"'
-
-exec "$JAVACMD" "$@"
gradlew.bat
deleted file mode 100644
index e509b2d..0000000
--- a/gradlew.bat
+++ /dev/null
@@ -1,93 +0,0 @@
-@rem

-@rem Copyright 2015 the original author or authors.

-@rem

-@rem Licensed under the Apache License, Version 2.0 (the "License");

-@rem you may not use this file except in compliance with the License.

-@rem You may obtain a copy of the License at

-@rem

-@rem      https://www.apache.org/licenses/LICENSE-2.0

-@rem

-@rem Unless required by applicable law or agreed to in writing, software

-@rem distributed under the License is distributed on an "AS IS" BASIS,

-@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.

-@rem See the License for the specific language governing permissions and

-@rem limitations under the License.

-@rem

-@rem SPDX-License-Identifier: Apache-2.0

-@rem

-

-@if "%DEBUG%"=="" @echo off

-@rem ##########################################################################

-@rem

-@rem  Gradle startup script for Windows

-@rem

-@rem ##########################################################################

-

-@rem Set local scope for the variables with windows NT shell

-if "%OS%"=="Windows_NT" setlocal

-

-set DIRNAME=%~dp0

-if "%DIRNAME%"=="" set DIRNAME=.

-@rem This is normally unused

-set APP_BASE_NAME=%~n0

-set APP_HOME=%DIRNAME%

-

-@rem Resolve any "." and ".." in APP_HOME to make it shorter.

-for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi

-

-@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.

-set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"

-

-@rem Find java.exe

-if defined JAVA_HOME goto findJavaFromJavaHome

-

-set JAVA_EXE=java.exe

-%JAVA_EXE% -version >NUL 2>&1

-if %ERRORLEVEL% equ 0 goto execute

-

-echo. 1>&2

-echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2

-echo. 1>&2

-echo Please set the JAVA_HOME variable in your environment to match the 1>&2

-echo location of your Java installation. 1>&2

-

-goto fail

-

-:findJavaFromJavaHome

-set JAVA_HOME=%JAVA_HOME:"=%

-set JAVA_EXE=%JAVA_HOME%/bin/java.exe

-

-if exist "%JAVA_EXE%" goto execute

-

-echo. 1>&2

-echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2

-echo. 1>&2

-echo Please set the JAVA_HOME variable in your environment to match the 1>&2

-echo location of your Java installation. 1>&2

-

-goto fail

-

-:execute

-@rem Setup the command line

-

-

-

-@rem Execute Gradle

-"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*

-

-:end

-@rem End local scope for the variables with windows NT shell

-if %ERRORLEVEL% equ 0 goto mainEnd

-

-:fail

-rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of

-rem the _cmd.exe /c_ return code!

-set EXIT_CODE=%ERRORLEVEL%

-if %EXIT_CODE% equ 0 set EXIT_CODE=1

-if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%

-exit /b %EXIT_CODE%

-

-:mainEnd

-if "%OS%"=="Windows_NT" endlocal

-

-:omega

service/README.md
new file mode 100644
index 0000000..e8e2e5e
--- /dev/null
+++ b/service/README.md
@@ -0,0 +1,10 @@
+# gitweb2 service
+
+The service file runs the OCaml `gitweb2` executable. Build the project first:
+
+```sh
+dune build
+```
+
+Then point `ExecStart` at the built executable or an installed `gitweb2` on
+`PATH`.
service/gitweb2.service
new file mode 100644
index 0000000..6113451
--- /dev/null
+++ b/service/gitweb2.service
@@ -0,0 +1,16 @@
+[Unit]
+Description=gitweb2 OCaml Git repository browser
+After=network-online.target
+Wants=network-online.target
+StartLimitIntervalSec=300
+StartLimitBurst=1
+
+[Service]
+Type=simple
+WorkingDirectory=/home/arjun/repos/homebox/gitweb2
+ExecStart=/usr/bin/env jai -j agents dune exec gitweb2 -- /home/git/repos --host 0.0.0.0 --port 8002 --pygments "uv run --with pygments pygmentize"
+Restart=on-failure
+RestartSec=5
+
+[Install]
+WantedBy=default.target
settings.gradle.kts
deleted file mode 100644
index f2f1cc9..0000000
--- a/settings.gradle.kts
+++ /dev/null
@@ -1,15 +0,0 @@
-pluginManagement {
-    repositories {
-        gradlePluginPortal()
-        mavenCentral()
-    }
-}
-
-dependencyResolutionManagement {
-    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
-    repositories {
-        mavenCentral()
-    }
-}
-
-rootProject.name = "gitweb2"
src/dune
new file mode 100644
index 0000000..d967ae7
--- /dev/null
+++ b/src/dune
@@ -0,0 +1,11 @@
+(library
+ (name gitweb2)
+ (public_name gitweb2)
+ (modules gitweb2)
+ (libraries ocaml-git unix uri cohttp-eio eio_main))
+
+(executable
+ (name main)
+ (public_name gitweb2)
+ (modules main)
+ (libraries gitweb2))
src/gitweb2.ml
new file mode 100644
index 0000000..14b3db3
--- /dev/null
+++ b/src/gitweb2.ml
@@ -0,0 +1,406 @@
+type response = {
+  status : int;
+  body : string;
+}
+
+type config = {
+  root : string;
+  host : string;
+  port : int;
+  pygments_command : string;
+}
+
+type repo_info = {
+  key : string;
+  name : string;
+  path : string;
+}
+
+type repo_summary = {
+  branch : string;
+  summary : string;
+  short_id : string;
+}
+
+let default_host = "127.0.0.1"
+let default_port = 8080
+let default_pygments_command = "pygmentize"
+let max_scan_depth = 5
+
+let html value =
+  let buffer = Buffer.create (String.length value) in
+  String.iter
+    (function
+      | '&' -> Buffer.add_string buffer "&amp;"
+      | '<' -> Buffer.add_string buffer "&lt;"
+      | '>' -> Buffer.add_string buffer "&gt;"
+      | '"' -> Buffer.add_string buffer "&quot;"
+      | '\'' -> Buffer.add_string buffer "&#39;"
+      | char -> Buffer.add_char buffer char)
+    value;
+  Buffer.contents buffer
+
+let url_encode value = Uri.pct_encode value
+let url_decode value = Uri.pct_decode value
+
+let join_path base child =
+  match (base, child) with
+  | "", child -> child
+  | base, "" -> base
+  | base, child when String.ends_with ~suffix:"/" base -> base ^ child
+  | base, child -> base ^ "/" ^ child
+
+let split_path path = String.split_on_char '/' path |> List.filter (( <> ) "")
+
+let command_exists command =
+  let executable = String.trim command |> String.split_on_char ' ' |> List.hd in
+  if executable = "" then false
+  else if String.contains executable '/' then Sys.file_exists executable
+  else
+    match Sys.getenv_opt "PATH" with
+    | None -> false
+    | Some paths ->
+        String.split_on_char ':' paths
+        |> List.exists (fun dir -> Sys.file_exists (join_path dir executable))
+
+let read_all channel =
+  let buffer = Buffer.create 4096 in
+  let bytes = Bytes.create 4096 in
+  let rec loop () =
+    match input channel bytes 0 (Bytes.length bytes) with
+    | 0 -> Buffer.contents buffer
+    | n ->
+        Buffer.add_subbytes buffer bytes 0 n;
+        loop ()
+  in
+  loop ()
+
+let read_command_output ?input command =
+  let stdout, stdin, stderr = Unix.open_process_full command (Unix.environment ()) in
+  Option.iter (output_string stdin) input;
+  close_out stdin;
+  let out = read_all stdout in
+  let err = read_all stderr in
+  ignore err;
+  match Unix.close_process_full (stdout, stdin, stderr) with
+  | Unix.WEXITED 0 -> Some (String.map (function '\r' -> '\n' | c -> c) out)
+  | _ -> None
+
+let shell_quote = Filename.quote
+
+let file_render ?(pygments_command = default_pygments_command) ~file_path text =
+  let plain () = "<pre><code>" ^ html text ^ "</code></pre>" in
+  if not (command_exists pygments_command) then plain ()
+  else
+    let lexer =
+      read_command_output (pygments_command ^ " -N " ^ shell_quote file_path ^ " 2>/dev/null")
+      |> Option.map String.trim
+    in
+    let lexer_option =
+      match lexer with
+      | Some lexer when lexer <> "" -> "-l " ^ shell_quote lexer
+      | _ -> "-g"
+    in
+    let command = pygments_command ^ " -f html -O nowrap,noclasses=True " ^ lexer_option ^ " 2>/dev/null" in
+    match read_command_output ~input:text command with
+    | Some highlighted when String.trim highlighted <> "" -> "<div class=\"highlight\">" ^ highlighted ^ "</div>"
+    | _ -> plain ()
+
+let is_readme name = name = "README.md" || name = "README.txt" || name = "README"
+
+let page title body =
+  "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n\
+   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>"
+  ^ html title
+  ^ " &middot; gitweb2</title>\n<style>\n\
+     :root { color-scheme: light; --ink: #1f2428; --muted: #586069; --line: #d0d7de; --wash: #f6f8fa; --link: #0969da; --accent: #1a7f37; }\n\
+     * { box-sizing: border-box; }\n\
+     body { margin: 0; font: 15px/1.5 -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif; color: var(--ink); background: #fff; }\n\
+     a { color: var(--link); text-decoration: none; } a:hover { text-decoration: underline; }\n\
+     main { max-width: 1180px; margin: 0 auto; padding: 28px 20px 56px; }\n\
+     .hero, .repo-head { border-bottom: 1px solid var(--line); margin-bottom: 20px; padding-bottom: 16px; }\n\
+     .eyebrow, .repo-head p, .file-meta, .kind, small, .commits span, .repo-list span { color: var(--muted); }\n\
+     h1 { margin: 4px 0 8px; font-size: 28px; line-height: 1.2; overflow-wrap: anywhere; }\n\
+     h2 { margin: 0 0 10px; font-size: 20px; line-height: 1.25; overflow-wrap: anywhere; }\n\
+     .repo-list, .commits { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }\n\
+     .repo-list li, .commits li { border-top: 1px solid var(--line); } .repo-list li:first-child, .commits li:first-child { border-top: 0; }\n\
+     .repo-list a, .commits li { display: grid; gap: 3px; padding: 14px 16px; }\n\
+     .repo-list strong, .commits strong { font-size: 16px; overflow-wrap: anywhere; } .repo-list small, .commits span { display: block; overflow-wrap: anywhere; }\n\
+     .panel { border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }\n\
+     table { width: 100%; border-collapse: collapse; } td { padding: 10px 12px; border-top: 1px solid var(--line); vertical-align: top; overflow-wrap: anywhere; }\n\
+     tr:first-child td { border-top: 0; } .kind { width: 120px; text-align: right; }\n\
+     .toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 12px 0; } .toolbar nav, .tabs { display: flex; gap: 8px; flex-wrap: wrap; }\n\
+     .toolbar a, .tabs a { border: 1px solid var(--line); border-radius: 8px; padding: 5px 9px; color: var(--ink); background: #fff; }\n\
+     .toolbar a.active, .tabs a.active { border-color: var(--accent); color: var(--accent); font-weight: 600; }\n\
+     .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; }\n\
+     .highlight { margin: 0; padding: 16px; overflow: auto; background: var(--wash); } .highlight pre { margin: 0; padding: 0; background: transparent; }\n\
+     .highlight, .highlight pre { font: 13px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre; }\n\
+     .readme { margin-top: 24px; } .file-meta { padding: 10px 12px; border-bottom: 1px solid var(--line); background: #fff; }\n\
+     .notice { padding: 16px; border: 1px solid var(--line); border-radius: 8px; background: var(--wash); }\n\
+     @media (max-width: 640px) { main { padding: 20px 12px 40px; } h1 { font-size: 23px; } .kind { width: 78px; } }\n\
+     </style>\n</head>\n<body><main>"
+  ^ body ^ "</main></body>\n</html>"
+
+let normalize_root root =
+  let expanded =
+    if root = "~" then Sys.getenv_opt "HOME" |> Option.value ~default:root
+    else if String.starts_with ~prefix:"~/" root then
+      match Sys.getenv_opt "HOME" with
+      | Some home -> home ^ "/" ^ String.sub root 2 (String.length root - 2)
+      | None -> root
+    else root
+  in
+  try Unix.realpath expanded |> String.trim with _ -> String.trim expanded
+
+let is_git_repo path =
+  Sys.file_exists (join_path path ".git")
+  || (Sys.file_exists (join_path path "HEAD") && Sys.file_exists (join_path path "objects")
+     && Sys.file_exists (join_path path "refs"))
+
+let is_openable_git_repo path =
+  try Ocaml_git.with_repo path (fun _ -> true) with _ -> false
+
+let discover_repos root =
+  let root = normalize_root root in
+  let found = ref [] in
+  let rec scan path relative depth =
+    if is_git_repo path then (
+      if is_openable_git_repo path then
+        let key = if relative = "" then Filename.basename path else relative in
+        found := { key; name = key; path } :: !found)
+    else if depth < max_scan_depth then
+      try
+        Sys.readdir path
+        |> Array.to_list
+        |> List.iter (fun name ->
+               if name <> "." && name <> ".." && name <> ".git" then
+                 scan (join_path path name) (if relative = "" then name else relative ^ "/" ^ name) (depth + 1))
+      with _ -> ()
+  in
+  scan root "" 0;
+  List.sort (fun a b -> String.compare a.name b.name) !found
+
+let local_branches repo = Ocaml_git.branches repo |> List.filter (fun (b : Ocaml_git.branch) -> b.kind = Ocaml_git.Local)
+
+let repo_url repo branch path =
+  let prefix = "/repo/" ^ url_encode repo.key ^ "/" ^ url_encode branch in
+  if path = "" then prefix else prefix ^ "/" ^ (split_path path |> List.map url_encode |> String.concat "/")
+
+let repo_summary repo =
+  try
+    Ocaml_git.with_repo repo.path @@ fun git ->
+    let branches = local_branches git in
+    let branch =
+      match List.find_opt (fun b -> b.Ocaml_git.is_head) branches with
+      | Some b -> b.name
+      | None -> (
+          match branches with
+          | b :: _ -> b.name
+          | [] -> "main")
+    in
+    let commit = try Some (Ocaml_git.branch_commit git branch) with _ -> None in
+    {
+      branch;
+      summary = (match commit with Some c -> c.summary | None -> "No commits");
+      short_id = (match commit with Some c -> String.sub c.id 0 (min 12 (String.length c.id)) | None -> "");
+    }
+  with _ -> { branch = "main"; summary = "Unavailable"; short_id = "" }
+
+let entries_for_display entries =
+  List.sort (fun (a : Ocaml_git.tree_entry) b -> String.compare (String.lowercase_ascii a.name) (String.lowercase_ascii b.name)) entries
+
+let rec repo_header repo branch path branches selected =
+  let buffer = Buffer.create 512 in
+  Buffer.add_string buffer ("<section class=\"repo-head\"><p><a href=\"/\">Repositories</a> / " ^ html repo.name ^ "</p>");
+  Buffer.add_string buffer ("<h1>" ^ html (if path = "" then repo.name else path) ^ "</h1>");
+  Buffer.add_string buffer "<div class=\"toolbar\"><span>Branch</span><nav>";
+  List.iter
+    (fun (item : Ocaml_git.branch) ->
+      let active = if item.name = branch then " class=\"active\"" else "" in
+      Buffer.add_string buffer ("<a" ^ active ^ " href=\"" ^ repo_url repo item.name path ^ "\">" ^ html item.name ^ "</a>"))
+    branches;
+  Buffer.add_string buffer "</nav></div><div class=\"tabs\">";
+  let tab id label href =
+    let active = if id = selected then " class=\"active\"" else "" in
+    Buffer.add_string buffer ("<a" ^ active ^ " href=\"" ^ href ^ "\">" ^ label ^ "</a>")
+  in
+  tab "code" "Code" (repo_url repo branch path);
+  tab "history" "History" ("/repo/" ^ url_encode repo.key ^ "/" ^ url_encode branch ^ "/-/commits");
+  Buffer.add_string buffer "</div></section>";
+  Buffer.contents buffer
+
+let tree_view ~pygments_command repo git branch path branches =
+  let commit = Ocaml_git.branch_commit git branch in
+  let listing = Ocaml_git.tree ~commit ~path git () in
+  let buffer = Buffer.create 4096 in
+  Buffer.add_string buffer (repo_header repo branch path branches "code");
+  Buffer.add_string buffer "<div class=\"panel\"><table><tbody>";
+  if path <> "" then (
+    let parent = try Filename.dirname path with _ -> "" in
+    let parent = if parent = "." then "" else parent in
+    Buffer.add_string buffer ("<tr><td><a href=\"" ^ repo_url repo branch parent ^ "\">..</a></td><td class=\"kind\">tree</td></tr>")
+  ) else ();
+  let display_entries = entries_for_display listing.entries in
+  List.iter
+    (fun (entry : Ocaml_git.tree_entry) ->
+      let child = join_path path entry.name in
+      Buffer.add_string buffer ("<tr><td><a href=\"" ^ repo_url repo branch child ^ "\">" ^ html entry.name ^ "</a></td>");
+      let kind =
+        match entry.kind with Blob -> "blob" | Tree -> "tree" | Commit -> "commit" | Tag -> "tag" | Other -> "other"
+      in
+      Buffer.add_string buffer ("<td class=\"kind\">" ^ kind ^ "</td></tr>"))
+    display_entries;
+  Buffer.add_string buffer "</tbody></table></div>";
+  (match List.find_opt (fun (entry : Ocaml_git.tree_entry) -> is_readme entry.name && entry.kind = Blob) display_entries with
+  | None -> ()
+  | Some readme ->
+      let readme_path = join_path path readme.name in
+      let blob = Ocaml_git.blob ~commit git readme_path in
+      if not (Ocaml_git.blob_is_binary blob) then (
+        Buffer.add_string buffer "<section class=\"readme\">";
+        Buffer.add_string buffer ("<h2>" ^ html readme.name ^ "</h2><div class=\"panel file\">");
+        Buffer.add_string buffer (file_render ~pygments_command ~file_path:(join_path repo.path readme_path) (Ocaml_git.blob_text blob));
+        Buffer.add_string buffer "</div></section>")
+      else ());
+  Buffer.contents buffer
+
+let blob_view ~pygments_command repo blob branch path branches =
+  let buffer = Buffer.create 4096 in
+  Buffer.add_string buffer (repo_header repo branch path branches "code");
+  Buffer.add_string buffer "<div class=\"panel file\">";
+  Buffer.add_string buffer
+    ("<div class=\"file-meta\">" ^ string_of_int (String.length blob.Ocaml_git.bytes) ^ " bytes &middot; "
+   ^ html (String.sub blob.id 0 (min 12 (String.length blob.id))) ^ "</div>");
+  if Ocaml_git.blob_is_binary blob then Buffer.add_string buffer "<p class=\"notice\">Binary file</p>"
+  else Buffer.add_string buffer (file_render ~pygments_command ~file_path:(join_path repo.path path) (Ocaml_git.blob_text blob));
+  Buffer.add_string buffer "</div>";
+  Buffer.contents buffer
+
+let commits_page repo branch =
+  Ocaml_git.with_repo repo.path @@ fun git ->
+  let buffer = Buffer.create 4096 in
+  Buffer.add_string buffer (repo_header repo branch "" (local_branches git) "history");
+  Buffer.add_string buffer "<ol class=\"commits\">";
+  List.iter
+    (fun (commit : Ocaml_git.commit) ->
+      Buffer.add_string buffer
+        ("<li><strong>" ^ html commit.summary ^ "</strong><span>"
+       ^ html (String.sub commit.id 0 (min 12 (String.length commit.id)))
+       ^ " &middot; " ^ html commit.author.name ^ "</span></li>"))
+    (Ocaml_git.commits ~limit:100 git branch);
+  Buffer.add_string buffer "</ol>";
+  page (branch ^ " history") (Buffer.contents buffer)
+
+let repo_page ~pygments_command repo branch path =
+  Ocaml_git.with_repo repo.path @@ fun git ->
+  let branches = local_branches git in
+  let commit = Ocaml_git.branch_commit git branch in
+  let entry = if path = "" then None else try Some (Ocaml_git.tree_entry ~commit git path) with _ -> None in
+  let content =
+    match entry with
+    | None | Some { kind = Tree; _ } -> tree_view ~pygments_command repo git branch path branches
+    | Some _ -> blob_view ~pygments_command repo (Ocaml_git.blob ~commit git path) branch path branches
+  in
+  page repo.name content
+
+let not_found message = { status = 404; body = page "Not found" ("<p class=\"notice\">" ^ message ^ "</p>") }
+
+let route ?(pygments_command = default_pygments_command) ~root raw_path =
+  let repos = discover_repos root in
+  let repo_by_key key = List.find_opt (fun repo -> repo.key = key) repos in
+  let parts = split_path raw_path in
+  try
+    match parts with
+    | [] ->
+        let buffer = Buffer.create 4096 in
+        Buffer.add_string buffer ("<section class=\"hero\"><p class=\"eyebrow\">" ^ html root ^ "</p><h1>Repositories</h1></section>");
+        if repos = [] then Buffer.add_string buffer "<p class=\"notice\">No Git repositories were found under this path.</p>"
+        else (
+          Buffer.add_string buffer "<ol class=\"repo-list\">";
+          List.iter
+            (fun repo ->
+              let summary = repo_summary repo in
+              Buffer.add_string buffer ("<li><a href=\"" ^ repo_url repo summary.branch "" ^ "\"><strong>" ^ html repo.name ^ "</strong>");
+              Buffer.add_string buffer ("<span>" ^ html summary.branch);
+              if summary.short_id <> "" then Buffer.add_string buffer (" &middot; " ^ html summary.short_id);
+              Buffer.add_string buffer ("</span><small>" ^ html summary.summary ^ "</small></a></li>"))
+            repos;
+          Buffer.add_string buffer "</ol>");
+        { status = 200; body = page "Repositories" (Buffer.contents buffer) }
+    | "repo" :: repo_key :: rest -> (
+        let repo_key = url_decode repo_key in
+        match repo_by_key repo_key with
+        | None -> not_found ("Unknown repository " ^ html repo_key)
+        | Some repo -> (
+            match rest with
+            | [] ->
+                let branch =
+                  Ocaml_git.with_repo repo.path @@ fun git ->
+                  match List.find_opt (fun b -> b.Ocaml_git.is_head) (local_branches git) with
+                  | Some b -> b.name
+                  | None -> (
+                      match local_branches git with
+                      | b :: _ -> b.name
+                      | [] -> "main")
+                in
+                { status = 200; body = repo_page ~pygments_command repo branch "" }
+            | branch :: "-" :: "commits" :: [] -> { status = 200; body = commits_page repo (url_decode branch) }
+            | branch :: path_parts ->
+                let path = path_parts |> List.map url_decode |> String.concat "/" in
+                { status = 200; body = repo_page ~pygments_command repo (url_decode branch) path }))
+    | _ -> not_found ("No route for " ^ html raw_path)
+  with
+  | Ocaml_git.Git_error message -> { status = 404; body = page "Not found" ("<p class=\"notice\">" ^ html message ^ "</p>") }
+  | exn -> { status = 500; body = page "Server error" ("<p class=\"notice\">" ^ html (Printexc.to_string exn) ^ "</p>") }
+
+let start config =
+  Eio_main.run @@ fun env ->
+  Eio.Switch.run @@ fun sw ->
+  let net = Eio.Stdenv.net env in
+  let ipaddr =
+    match config.host with
+    | "127.0.0.1" | "localhost" -> Eio.Net.Ipaddr.V4.loopback
+    | "0.0.0.0" -> Eio.Net.Ipaddr.V4.any
+    | "::1" -> Eio.Net.Ipaddr.V6.loopback
+    | "::" -> Eio.Net.Ipaddr.V6.any
+    | other -> invalid_arg ("unsupported listen host: " ^ other)
+  in
+  let sockaddr = `Tcp (ipaddr, config.port) in
+  let socket = Eio.Net.listen ~sw ~reuse_addr:true ~backlog:128 net sockaddr in
+  Printf.printf "gitweb2 serving repositories from %s at http://%s:%d/\n%!" config.root config.host config.port;
+  let server =
+    Cohttp_eio.Server.make
+      ~callback:(fun _conn request _body ->
+        let path = Cohttp.Request.resource request in
+        let response = route ~pygments_command:config.pygments_command ~root:config.root path in
+        let headers = Cohttp.Header.init_with "Cache-Control" "no-store" |> fun h -> Cohttp.Header.add h "Content-Type" "text/html; charset=utf-8" in
+        Cohttp_eio.Server.respond_string ~headers ~status:(`Code response.status) ~body:response.body ())
+      ()
+  in
+  Cohttp_eio.Server.run socket server ~on_error:(fun exn -> prerr_endline (Printexc.to_string exn))
+
+let usage = "usage: gitweb2 <repo-root> [--host 127.0.0.1] [--port 8080] [--pygments pygmentize]"
+
+let parse_args argv =
+  let args = Array.to_list argv |> List.tl in
+  match args with
+  | [] | ("-h" | "--help") :: _ -> Error usage
+  | root :: rest ->
+      let rec loop host port pygments = function
+        | [] -> Ok { root; host; port; pygments_command = pygments }
+        | "--host" :: value :: tail -> loop value port pygments tail
+        | "--port" :: value :: tail -> (
+            match int_of_string_opt value with
+            | Some port -> loop host port pygments tail
+            | None -> Error "missing or invalid --port value")
+        | "--pygments" :: value :: tail -> loop host port value tail
+        | flag :: _ -> Error ("unknown argument " ^ flag)
+      in
+      loop default_host default_port default_pygments_command rest
+
+let run argv =
+  match parse_args argv with
+  | Ok config -> start config
+  | Error message ->
+      prerr_endline message;
+      if message <> usage then exit 2
src/gitweb2.mli
new file mode 100644
index 0000000..758735f
--- /dev/null
+++ b/src/gitweb2.mli
@@ -0,0 +1,20 @@
+type response = {
+  status : int;
+  body : string;
+}
+
+type config = {
+  root : string;
+  host : string;
+  port : int;
+  pygments_command : string;
+}
+
+val default_host : string
+val default_port : int
+val default_pygments_command : string
+val parse_args : string array -> (config, string) result
+val route : ?pygments_command:string -> root:string -> string -> response
+val start : config -> unit
+val file_render : ?pygments_command:string -> file_path:string -> string -> string
+val run : string array -> unit
src/main.ml
new file mode 100644
index 0000000..6b3d2d4
--- /dev/null
+++ b/src/main.ml
@@ -0,0 +1 @@
+let () = Gitweb2.run Sys.argv
src/main/kotlin/AppMain.kt
deleted file mode 100644
index 82da7eb..0000000
--- a/src/main/kotlin/AppMain.kt
+++ /dev/null
@@ -1,3 +0,0 @@
-fun main(args: Array<String>) {
-    gitweb.runGitWeb2(args)
-}
src/main/kotlin/Main.kt
deleted file mode 100644
index 0caf366..0000000
--- a/src/main/kotlin/Main.kt
+++ /dev/null
@@ -1,513 +0,0 @@
-package gitweb
-
-import io.ktor.http.ContentType
-import io.ktor.http.HttpStatusCode
-import io.ktor.http.withCharset
-import io.ktor.server.application.call
-import io.ktor.server.cio.CIO
-import io.ktor.server.engine.embeddedServer
-import io.ktor.server.request.path
-import io.ktor.server.response.header
-import io.ktor.server.response.respondText
-import io.ktor.server.routing.get
-import io.ktor.server.routing.routing
-import io.ktor.utils.io.charsets.Charsets
-import io.matthewnelson.kmp.process.Process as KmpProcess
-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 okio.FileSystem
-import okio.Path.Companion.toPath
-import kotlin.system.exitProcess
-
-private const val DefaultHost = "127.0.0.1"
-private const val DefaultPort = 8080
-private const val MaxScanDepth = 5
-private const val DefaultPygmentsCommand = "pygmentize"
-
-fun runGitWeb2(args: Array<String>) {
-    val config = parseArgs(args) ?: return
-    GitWebServer(config.root, config.host, config.port, config.pygmentsCommand).start()
-}
-
-private data class Config(val root: String, val host: String, val port: Int, val pygmentsCommand: String)
-
-private data class RepoInfo(val key: String, val name: String, val path: String)
-
-internal data class Response(val status: Int, val body: String)
-
-internal class GitWebServer(
-    private val root: String,
-    private val host: String,
-    private val port: Int,
-    private val pygmentsCommand: String = DefaultPygmentsCommand,
-) {
-    private val repos: List<RepoInfo> = discoverRepos(root)
-    private val reposByKey: Map<String, RepoInfo> = repos.associateBy { it.key }
-    private val fileRenderer = FileRenderer(pygmentsCommand)
-
-    fun start() {
-        println("gitweb2 serving ${repos.size} repositories from $root at http://$host:$port/")
-        val server = embeddedServer(CIO, host = host, port = port) {
-            routing {
-                get("{...}") {
-                    val response = route(call.request.path())
-                    call.response.header("Cache-Control", "no-store")
-                    call.respondText(
-                        text = response.body,
-                        contentType = ContentType.Text.Html.withCharset(Charsets.UTF_8),
-                        status = HttpStatusCode.fromValue(response.status),
-                    )
-                }
-            }
-        }
-        try {
-            withTerminateSignalsHandled(onTerminate = { server.stop(500, 1000) }) {
-                server.start(wait = true)
-            }
-        } finally {
-            server.stop()
-            Git.shutdown()
-        }
-    }
-
-    fun route(rawPath: String): Response {
-        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(" &middot; ${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 commit = git.branchCommit(branch)
-        val listing = git.tree(path, commit)
-        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.entriesForDisplay()) {
-                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>")
-            val readme = listing.entries.entriesForDisplay().firstOrNull { isReadme(it.name) && it.kind == TreeEntryKind.Blob }
-            if (readme != null) {
-                val readmePath = joinPath(path, readme.name)
-                val blob = git.blob(readmePath, commit)
-                if (!blob.isBinary) {
-                    append("<section class=\"readme\">")
-                    append("<h2>${html(readme.name)}</h2>")
-                    append("<div class=\"panel file\">")
-                    append(fileRenderer.render(joinPath(repo.path, readmePath), blob.text()))
-                    append("</div>")
-                    append("</section>")
-                }
-            }
-        }
-    }
-
-    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 &middot; ${html(blob.id.value.take(12))}</div>")
-        if (blob.isBinary) {
-            append("<p class=\"notice\">Binary file</p>")
-        } else {
-            append(fileRenderer.render(joinPath(repo.path, path), blob.text()))
-        }
-        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))} &middot; ${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 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)} &middot; 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; }
-h2 { margin: 0 0 10px; font-size: 20px; line-height: 1.25; 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; }
-.highlight { margin: 0; padding: 16px; overflow: auto; background: var(--wash); }
-.highlight pre { margin: 0; padding: 0; background: transparent; }
-.highlight, .highlight pre { font: 13px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre; }
-.readme { margin-top: 24px; }
-.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()
-
-internal class FileRenderer(private val pygmentsCommand: String = DefaultPygmentsCommand) {
-    fun render(filePath: String, text: String): String =
-        renderWithPygments(filePath, text) ?: plain(text)
-
-    private fun renderWithPygments(filePath: String, text: String): String? {
-        if (!commandExists(pygmentsCommand)) return null
-        val lexer = readCommandOutput("$pygmentsCommand -N ${shellQuote(filePath)} 2>/dev/null")?.trim()
-        val lexerOption = if (lexer.isNullOrBlank()) "-g" else "-l ${shellQuote(lexer)}"
-        val command = "$pygmentsCommand -f html -O nowrap,noclasses=True $lexerOption 2>/dev/null"
-        val highlighted = readCommandOutput(command, text) ?: return null
-        if (highlighted.isBlank()) return null
-        return "<div class=\"highlight\">$highlighted</div>"
-    }
-
-    private fun plain(text: String): String =
-        "<pre><code>${html(text)}</code></pre>"
-}
-
-private fun List<kotlinx.git.TreeEntry>.entriesForDisplay(): List<kotlinx.git.TreeEntry> =
-    sortedBy { it.name.lowercase() }
-
-private fun isReadme(name: String): Boolean =
-    name == "README.md" || name == "README.txt" || name == "README"
-
-private fun shellQuote(value: String): String =
-    "'" + value.replace("'", "'\"'\"'") + "'"
-
-private fun commandExists(command: String): Boolean {
-    val executable = command.trim().substringBefore(' ')
-    if (executable.isBlank()) return false
-    if ('/' in executable) return pathExists(executable)
-    return KmpProcess.Current.environment()["PATH"]
-        ?.split(':')
-        ?.any { pathExists(joinPath(it, executable)) } == true
-}
-
-private fun readCommandOutput(command: String, input: String? = null): String? {
-    val output = runCatching {
-        KmpProcess.Builder("/bin/sh")
-            .args("-c", command)
-            .createOutput {
-                timeoutMillis = 10_000
-                if (input != null) inputUtf8 { input }
-            }
-    }.getOrNull() ?: return null
-    if (output.processError != null || output.processInfo.exitCode != 0) return null
-    return output.stdout.replace("\r\n", "\n").replace('\r', '\n')
-}
-
-private fun discoverRepos(root: String): List<RepoInfo> {
-    val fileSystem = FileSystem.SYSTEM
-    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 entries = runCatching { fileSystem.list(path.toPath()) }.getOrElse { return }
-        for (entry in entries) {
-            val name = entry.name
-            if (name == "." || name == ".." || name == ".git") continue
-            val child = entry.toString()
-            val childRelative = if (relative.isBlank()) name else "$relative/$name"
-            scan(child, childRelative, depth + 1)
-        }
-    }
-    scan(normalizeRoot(root), "", 0)
-    return found.sortedBy { it.name }
-}
-
-private fun Repository.localBranches(): List<Branch> =
-    branches().filter { it.type == BranchType.Local }
-
-private fun isGitRepo(path: String): Boolean =
-    pathExists(joinPath(path, ".git")) ||
-        pathExists(joinPath(path, "HEAD")) &&
-        pathExists(joinPath(path, "objects")) &&
-        pathExists(joinPath(path, "refs"))
-
-private fun pathExists(path: String): Boolean =
-    FileSystem.SYSTEM.metadataOrNull(path.toPath()) != null
-
-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 homeDirectory() ?: path
-    if (path.startsWith("~/")) return "${homeDirectory() ?: return path}/${path.drop(2)}"
-    return path
-}
-
-private fun normalizeRoot(path: String): String {
-    val expanded = expandHome(path)
-    return runCatching {
-        FileSystem.SYSTEM.canonicalize(expanded.toPath()).toString()
-    }.getOrElse {
-        expanded
-    }.trimEnd('/')
-}
-
-private fun homeDirectory(): String? =
-    KmpProcess.Current.environment()["HOME"]?.takeIf { it.isNotBlank() }
-
-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] [--pygments pygmentize]")
-        return null
-    }
-    var host = DefaultHost
-    var port = DefaultPort
-    var pygmentsCommand = DefaultPygmentsCommand
-    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")
-            "--pygments" -> pygmentsCommand = args.getOrNull(++index) ?: die("missing --pygments value")
-            else -> die("unknown argument ${args[index]}")
-        }
-        index++
-    }
-    return Config(root = args[0], host = host, port = port, pygmentsCommand = pygmentsCommand)
-}
-
-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/main/kotlin/TerminateSignals.kt
deleted file mode 100644
index fef4e38..0000000
--- a/src/main/kotlin/TerminateSignals.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-@file:OptIn(ExperimentalForeignApi::class)
-
-package gitweb
-
-import kotlinx.cinterop.ExperimentalForeignApi
-import kotlinx.cinterop.staticCFunction
-import platform.posix.SIG_DFL
-import platform.posix.SIGINT
-import platform.posix.SIGTERM
-import platform.posix.signal
-import kotlin.system.exitProcess
-
-/**
- * Runs [block] with SIGINT and SIGTERM wired to [onTerminate]. Restores default handlers and
- * clears state afterward. All POSIX / [staticCFunction] details stay in this file.
- */
-internal inline fun withTerminateSignalsHandled(crossinline onTerminate: () -> Unit, block: () -> Unit) {
-    terminateAction = { onTerminate() }
-    signal(SIGINT, terminateSignalHandler)
-    signal(SIGTERM, terminateSignalHandler)
-    try {
-        block()
-    } finally {
-        signal(SIGINT, SIG_DFL)
-        signal(SIGTERM, SIG_DFL)
-        terminateAction = null
-    }
-}
-
-private var terminateAction: (() -> Unit)? = null
-
-private val terminateSignalHandler = staticCFunction<Int, Unit> { _ ->
-    val action = terminateAction
-    if (action != null) {
-        action()
-    } else {
-        exitProcess(0)
-    }
-}
src/nativeTest/kotlin/gitweb/FileRendererTest.kt
deleted file mode 100644
index 781e279..0000000
--- a/src/nativeTest/kotlin/gitweb/FileRendererTest.kt
+++ /dev/null
@@ -1,54 +0,0 @@
-package gitweb
-
-import okio.FileSystem
-import okio.Path.Companion.toPath
-import kotlin.test.Test
-import kotlin.test.assertContains
-import kotlin.test.assertEquals
-
-class FileRendererTest {
-    private val repoRoot: String = FileSystem.SYSTEM.canonicalize(".".toPath()).toString()
-
-    @Test
-    fun filesCanBeRenderedThroughCustomPygmentsCommand() {
-        val html = FileRenderer("uv run --with pygments pygmentize").render(
-            "$repoRoot/src/main/kotlin/Main.kt",
-            """
-            private class Example {
-                fun answer(): Int = 42
-            }
-            """.trimIndent(),
-        )
-
-        assertContains(html, "<div class=\"highlight\">")
-        assertContains(html, "<span style=\"color: #008000; font-weight: bold\">private</span>")
-        assertContains(html, "<span style=\"color: #008000; font-weight: bold\">class</span>")
-        assertContains(html, "<span style=\"color: #008000; font-weight: bold\">fun</span>")
-        assertContains(html, "Example")
-    }
-
-    @Test
-    fun pygmentsUsesProvidedBlobTextEvenWhenThePathIsNotOnDisk() {
-        val html = FileRenderer("uv run --with pygments pygmentize").render(
-            "$repoRoot/not-checked-out/Example.kt",
-            """
-            private class BlobOnly {
-                fun answer(): Int = 42
-            }
-            """.trimIndent(),
-        )
-
-        assertContains(html, "<div class=\"highlight\">")
-        assertContains(html, "<span style=\"color: #008000; font-weight: bold\">private</span>")
-        assertContains(html, "<span style=\"color: #008000; font-weight: bold\">class</span>")
-        assertContains(html, "<span style=\"color: #008000; font-weight: bold\">fun</span>")
-        assertContains(html, "BlobOnly")
-    }
-
-    @Test
-    fun filesFallBackToEscapedPlainTextWhenPygmentsIsUnavailable() {
-        val html = FileRenderer("__missing_pygmentize__").render("$repoRoot/README.md", "<plain>")
-
-        assertEquals("<pre><code>&lt;plain&gt;</code></pre>", html)
-    }
-}
src/nativeTest/kotlin/gitweb/GitWebServerTest.kt
deleted file mode 100644
index 88a9725..0000000
--- a/src/nativeTest/kotlin/gitweb/GitWebServerTest.kt
+++ /dev/null
@@ -1,46 +0,0 @@
-package gitweb
-
-import okio.FileSystem
-import okio.Path.Companion.toPath
-import kotlin.test.Test
-import kotlin.test.assertContains
-import kotlin.test.assertEquals
-import kotlin.test.assertNotEquals
-import kotlin.test.assertTrue
-
-class GitWebServerTest {
-    private val repoRoot: String = FileSystem.SYSTEM.canonicalize(".".toPath()).toString()
-    private val repoName: String = repoRoot.trimEnd('/').substringAfterLast('/')
-
-    @Test
-    fun directoryEntriesStayAlphabetical() {
-        val response = GitWebServer(repoRoot, "127.0.0.1", 0).route("/repo/$repoName")
-
-        assertEquals(200, response.status)
-        val buildIndex = response.body.indexOf(">build.gradle.kts</a>")
-        val settingsIndex = response.body.indexOf(">settings.gradle.kts</a>")
-        val readmeIndex = response.body.indexOf(">README.md</a>")
-
-        assertNotEquals(-1, buildIndex)
-        assertNotEquals(-1, settingsIndex)
-        assertNotEquals(-1, readmeIndex)
-        assertTrue(readmeIndex > buildIndex, "README.md should be listed after build.gradle.kts")
-        assertTrue(readmeIndex < settingsIndex, "README.md should be listed before settings.gradle.kts")
-    }
-
-    @Test
-    fun directoriesRenderTheirReadmeBelowTheListing() {
-        val response = GitWebServer(repoRoot, "127.0.0.1", 0, "uv run --with pygments pygmentize").route("/repo/$repoName")
-
-        assertEquals(200, response.status)
-        val listingIndex = response.body.indexOf(">README.md</a>")
-        val renderedIndex = response.body.indexOf("<h2>README.md</h2>")
-
-        assertNotEquals(-1, listingIndex)
-        assertNotEquals(-1, renderedIndex)
-        assertTrue(renderedIndex > listingIndex, "Rendered README should appear below the directory listing")
-        assertContains(response.body, "<div class=\"highlight\">")
-        assertContains(response.body, "gitweb2")
-        assertContains(response.body, "A Kotlin/Native, read-only web viewer")
-    }
-}
test/dune
new file mode 100644
index 0000000..ce5e8f6
--- /dev/null
+++ b/test/dune
@@ -0,0 +1,11 @@
+(executable
+ (name test_gitweb2)
+ (libraries alcotest gitweb2 unix))
+
+(rule
+ (alias runtest)
+ (deps test_gitweb2.exe)
+ (action
+  (chdir
+   %{workspace_root}
+   (run test/test_gitweb2.exe))))
test/test_gitweb2.ml
new file mode 100644
index 0000000..6d11b9b
--- /dev/null
+++ b/test/test_gitweb2.ml
@@ -0,0 +1,86 @@
+let work_root = ".build/test-work"
+let repo_root = work_root ^ "/repo"
+let repo_name = "repo"
+
+let sh command =
+  match Sys.command command with
+  | 0 -> ()
+  | code -> Alcotest.failf "command failed with %d: %s" code command
+
+let reset_repo () =
+  sh ("rm -rf " ^ work_root ^ " && mkdir -p " ^ repo_root);
+  sh ("git -C " ^ repo_root ^ " init -b main >/dev/null 2>&1");
+  sh ("git -C " ^ repo_root ^ " config user.name 'Fixture Author'");
+  sh ("git -C " ^ repo_root ^ " config user.email fixture@example.com");
+  sh ("printf 'plugins { }\\n' > " ^ repo_root ^ "/build.gradle.kts");
+  sh ("printf '# gitweb2\\n\\nA Kotlin/Native, read-only web viewer for local Git repositories.\\n' > " ^ repo_root ^ "/README.md");
+  sh ("printf 'pluginManagement { }\\n' > " ^ repo_root ^ "/settings.gradle.kts");
+  sh ("git -C " ^ repo_root ^ " add . && git -C " ^ repo_root ^ " commit -m 'Initial fixture commit' >/dev/null 2>&1")
+
+let contains haystack needle =
+  let hlen = String.length haystack and nlen = String.length needle in
+  let rec loop index =
+    index + nlen <= hlen && (String.sub haystack index nlen = needle || loop (index + 1))
+  in
+  nlen = 0 || loop 0
+
+let assert_contains label body needle = Alcotest.(check bool) label true (contains body needle)
+
+let directory_entries_stay_alphabetical () =
+  reset_repo ();
+  let response = Gitweb2.route ~root:work_root ("/repo/" ^ repo_name) in
+  Alcotest.(check int) "status" 200 response.status;
+  let index needle = try String.index_from response.body 0 needle.[0] with _ -> -1 in
+  let find needle =
+    let rec loop index =
+      if index + String.length needle > String.length response.body then -1
+      else if String.sub response.body index (String.length needle) = needle then index
+      else loop (index + 1)
+    in
+    loop 0
+  in
+  let build_index = find ">build.gradle.kts</a>" in
+  let settings_index = find ">settings.gradle.kts</a>" in
+  let readme_index = find ">README.md</a>" in
+  ignore index;
+  Alcotest.(check bool) "build present" true (build_index >= 0);
+  Alcotest.(check bool) "settings present" true (settings_index >= 0);
+  Alcotest.(check bool) "readme present" true (readme_index >= 0);
+  Alcotest.(check bool) "readme after build" true (readme_index > build_index);
+  Alcotest.(check bool) "readme before settings" true (readme_index < settings_index)
+
+let directories_render_their_readme_below_the_listing () =
+  reset_repo ();
+  let response = Gitweb2.route ~pygments_command:"uv run --with pygments pygmentize" ~root:work_root ("/repo/" ^ repo_name) in
+  Alcotest.(check int) "status" 200 response.status;
+  let find needle =
+    let rec loop index =
+      if index + String.length needle > String.length response.body then -1
+      else if String.sub response.body index (String.length needle) = needle then index
+      else loop (index + 1)
+    in
+    loop 0
+  in
+  let listing_index = find ">README.md</a>" in
+  let rendered_index = find "<h2>README.md</h2>" in
+  Alcotest.(check bool) "listing present" true (listing_index >= 0);
+  Alcotest.(check bool) "rendered present" true (rendered_index >= 0);
+  Alcotest.(check bool) "below listing" true (rendered_index > listing_index);
+  assert_contains "highlight" response.body "<div class=\"highlight\">";
+  assert_contains "name" response.body "gitweb2";
+  assert_contains "readme text" response.body "read-only web viewer"
+
+let pygments_fallback () =
+  let html = Gitweb2.file_render ~pygments_command:"__missing_pygmentize__" ~file_path:"README.md" "<plain>" in
+  Alcotest.(check string) "fallback" "<pre><code>&lt;plain&gt;</code></pre>" html
+
+let () =
+  Alcotest.run "gitweb2"
+    [
+      ( "routing",
+        [
+          Alcotest.test_case "directory entries stay alphabetical" `Quick directory_entries_stay_alphabetical;
+          Alcotest.test_case "directories render their readme below the listing" `Quick directories_render_their_readme_below_the_listing;
+          Alcotest.test_case "pygments fallback" `Quick pygments_fallback;
+        ] );
+    ]