foxygit / foxygit Log in
A tiny read-only git web frontend — browse bare repos with just PHP and git, no database, no framework.
commit acf7ee09a95a3b21d3b0d438dc66909b20cd4e27
Author:     mrfox <jens.kristoffersson.se@gmail.com>
AuthorDate: Wed Aug 12 18:45:16 2026 +0200
Commit:     mrfox <jens.kristoffersson.se@gmail.com>
CommitDate: Wed Aug 12 18:45:16 2026 +0200

    Initial commit: foxygit, a read-only git web frontend

    Single-file-router PHP app (index.php -> inc/ -> views/), no database,
    no framework. Reads bare repos by shelling out to git. Includes the
    server-side tooling for hosting the repos themselves over SSH plus
    optional anonymous HTTPS clone.
---
 .gitignore                        |  12 ++
 README.md                         |  65 ++++++
 assets/app.js                     |  39 ++++
 assets/base.css                   | 267 +++++++++++++++++++++++++
 deploy.sh                         | 151 ++++++++++++++
 inc/config.example.php            |  16 ++
 inc/functions.php                 | 402 ++++++++++++++++++++++++++++++++++++++
 inc/render.php                    |  10 +
 index.php                         | 232 ++++++++++++++++++++++
 install.sh                        |  46 +++++
 retire-stagit.sh                  |  73 +++++++
 server/add-key.sh                 |  75 +++++++
 server/fcgiwrap-git.service       |  14 ++
 server/fcgiwrap-git.socket        |  11 ++
 server/git-shell-commands/create  |  37 ++++
 server/setup-anon-clone.sh        | 156 +++++++++++++++
 server/setup-server.sh            |  72 +++++++
 themes/foxygit-dark.css           |  16 ++
 themes/foxygit-light.css          |  15 ++
 themes/github-dark.css            |  15 ++
 themes/github-light.css           |  15 ++
 views/atom-tags.php               |  17 ++
 views/atom.php                    |  17 ++
 views/blob.php                    |  13 ++
 views/commit.php                  |   5 +
 views/log.php                     |  29 +++
 views/notfound.php                |   1 +
 views/partials/error.php          |   2 +
 views/partials/foot.php           |  10 +
 views/partials/head.php           |  44 +++++
 views/partials/repo-subnav.php    |  31 +++
 views/partials/tabs.php           |  15 ++
 views/partials/theme-switcher.php |  20 ++
 views/refs.php                    |  32 +++
 views/repo-index.php              |  17 ++
 views/tree.php                    |  42 ++++
 36 files changed, 2034 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..cbf7593
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+# Real deployment config — domain, filesystem paths. Copy inc/config.example.php
+# to inc/config.php and fill in your own values; config.php itself never gets
+# committed, so those specifics don't end up in this repo's history.
+/inc/config.php
+
+# OS / editor cruft
+.DS_Store
+Thumbs.db
+*.swp
+*.swo
+.idea/
+.vscode/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4a7ebe0
--- /dev/null
+++ b/README.md
@@ -0,0 +1,65 @@
+# foxygit
+
+A tiny read-only git web frontend, in the spirit of [stagit](https://codemadness.org/stagit.html)
+and [cgit](https://git.zx2c4.com/cgit/about/) — but a single PHP application instead of a static-site
+generator or a C CGI program. No database, no framework, no build step. It shells out to the `git`
+binary and renders what comes back.
+
+Push/pull is **not** handled here — that stays git-over-SSH against your bare repos (see
+[`server/`](server/)). This is only the read-only "shop window."
+
+## Features
+
+- Browse repos: file tree, commit log (grouped by day), branches/tags, colorized commit diffs.
+- README/LICENSE auto-detected from the repo root and rendered (a small, dependency-free
+  markdown → HTML converter — headings, lists, code, links, bold/italic).
+- Atom feeds for commits and tags.
+- Raw file download with correct `Content-Type` sniffing.
+- Anonymous, unauthenticated `git clone` over HTTPS, alongside SSH push for people with a key —
+  the same split GitHub/GitLab use for public repos (see `server/setup-anon-clone.sh`).
+- Themeable: colors live entirely in small CSS custom-property files under `themes/`; the
+  structural CSS in `assets/base.css` never hardcodes a color. Ships with a dark/light pair in
+  its own style plus a GitHub Primer–inspired dark/light pair. A sun/moon toggle switches within
+  a theme's family; a dropdown in the footer switches families.
+- Careful about the things a "just shell out to git" app can get wrong: leading-dash arguments
+  that could be read as flags, path traversal, unicode filenames, binary blobs, oversized diffs,
+  and command/argument injection in general.
+
+## Architecture
+
+```
+index.php          router only — resolves the request, asks inc/ for data, hands it to views/
+inc/
+  config.php         site-specific values (gitignored — see config.example.php)
+  functions.php       all git/data logic; never prints HTML
+  render.php            the one bridge between index.php and views/
+views/
+  partials/              header, footer, tab bar, subnav, theme switcher
+  *.php                    one file per page (tree, log, commit, refs, blob, atom, ...)
+assets/               structural CSS (base.css) + the one bit of JS (clone-to-clipboard)
+themes/               *.css files, each just a set of CSS custom properties
+server/               git hosting over SSH: user setup, key management, self-serve `create`,
+                      and the anonymous-HTTPS-clone route — independent of the web frontend
+```
+
+`inc/functions.php` never emits HTML; `views/*.php` never talks to git. `index.php` is the only
+place that knows both sides exist.
+
+## Deploy
+
+1. `cp inc/config.example.php inc/config.php` and fill in your domain and paths.
+2. `sudo bash install.sh` — sets up git hosting over SSH (dedicated user, bare-repo storage,
+   self-serve repo creation) *and* the web frontend (a php-fpm pool running as that user, since
+   the bare repos aren't readable by the default `www-data` pool, plus the Caddy block). Both
+   halves are idempotent and can be run standalone too — `server/setup-server.sh` for just the
+   git side, `deploy.sh` for just the web frontend.
+3. Optional: `sudo bash server/setup-anon-clone.sh` for unauthenticated HTTPS clone.
+4. Add collaborators' SSH keys with `sudo bash server/add-key.sh`.
+
+Everything assumes Debian + Caddy + php-fpm; adjust the paths in `inc/config.php` and the
+`server/` scripts for a different setup.
+
+## Requirements
+
+PHP 8+ with `exec()` and `proc_open()` enabled (on by default), and the `git` binary on `PATH`.
+That's the whole dependency list.
diff --git a/assets/app.js b/assets/app.js
new file mode 100644
index 0000000..8483c7b
--- /dev/null
+++ b/assets/app.js
@@ -0,0 +1,39 @@
+/* foxygit — the only JavaScript in the project: copy-to-clipboard for the
+   clone command. Everything else works without JS, and so does this: without
+   it the button is still a readable, selectable line of text. */
+
+document.addEventListener('click', function (ev) {
+  var btn = ev.target.closest('[data-copy]');
+  if (!btn) return;
+
+  copy(btn.dataset.copy).then(function (ok) {
+    if (!ok) return;
+    btn.classList.add('copied');
+    clearTimeout(btn._copyTimer);
+    btn._copyTimer = setTimeout(function () { btn.classList.remove('copied'); }, 1500);
+  });
+});
+
+/* navigator.clipboard needs a secure context (https or localhost). Falls back
+   to a hidden textarea + execCommand so plain-http access still works. */
+function copy(text) {
+  if (navigator.clipboard && window.isSecureContext) {
+    return navigator.clipboard.writeText(text).then(function () { return true; },
+                                                    function () { return legacyCopy(text); });
+  }
+  return Promise.resolve(legacyCopy(text));
+}
+
+function legacyCopy(text) {
+  var ta = document.createElement('textarea');
+  ta.value = text;
+  ta.setAttribute('readonly', '');
+  ta.style.position = 'fixed';
+  ta.style.opacity = '0';
+  document.body.appendChild(ta);
+  ta.select();
+  var ok = false;
+  try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
+  document.body.removeChild(ta);
+  return ok;
+}
diff --git a/assets/base.css b/assets/base.css
new file mode 100644
index 0000000..3287f35
--- /dev/null
+++ b/assets/base.css
@@ -0,0 +1,267 @@
+/* foxygit — structure only, no colors. All colors come from a theme file
+   in themes/*.css via CSS custom properties (--bg, --fg, --dim, --acc,
+   --line, --surface, --pre-bg, --add, --del, --hunk, --font-ui). */
+
+* { box-sizing: border-box; }
+
+body {
+  margin: 0;
+  background: var(--bg);
+  color: var(--fg);
+  font: 14px/1.5 var(--font-ui);
+  -webkit-font-smoothing: antialiased;
+}
+
+a { color: var(--acc); text-decoration: none; }
+a:hover { text-decoration: underline; }
+
+/* code/hashes/diffs always render monospace, regardless of --font-ui */
+pre, code, .mono, .hash {
+  font-family: ui-monospace, "JetBrains Mono", Menlo, Consolas, monospace;
+}
+
+/* middle, not text-bottom: table cells set their own line-height, and
+   text-bottom pushed icons visibly below the row's text baseline. */
+.icon { fill: currentColor; vertical-align: middle; flex: none; }
+
+/* the shared centred column — header, tabs and main all line up on it */
+.inner { max-width: 1100px; margin: 0 auto; padding: 0 24px; width: 100%; }
+
+/* ---------------------------------------------------------------- header */
+
+header {
+  border-bottom: 1px solid var(--line);
+  background: var(--surface);
+  padding: 12px 0;
+}
+header .inner { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 10px; }
+header .brand { color: var(--fg); font-weight: 600; font-size: 16px; display: inline-flex; align-items: center; gap: 8px; }
+header .brand .icon { color: var(--dim); }
+header .brand:hover { text-decoration: none; }
+header .path { color: var(--dim); display: inline-flex; align-items: center; gap: 6px; font-size: 16px; }
+header .path a { color: var(--fg); font-weight: 600; }
+header .spacer { flex: 1; }
+
+.theme-toggle {
+  color: var(--dim);
+  border: 1px solid transparent;
+  border-radius: 6px;
+  padding: 5px;
+  display: inline-flex;
+  line-height: 0;
+}
+.theme-toggle:hover { color: var(--fg); background: var(--bg); border-color: var(--line); text-decoration: none; }
+
+main { padding: 24px 0 0; }
+
+/* ----------------------------------------------------------------- tabs */
+
+nav.tabs { border-bottom: 1px solid var(--line); }
+nav.tabs .inner { display: flex; gap: 4px; }
+nav.tabs a {
+  color: var(--dim);
+  padding: 10px 12px;
+  border-bottom: 2px solid transparent;
+  margin-bottom: -1px;
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+}
+nav.tabs a:hover { color: var(--fg); text-decoration: none; }
+nav.tabs a.on { color: var(--fg); font-weight: 600; border-bottom-color: var(--acc); }
+
+/* --------------------------------------------------------------- subnav:
+   branch pills + clone URL + feed links, the row above the content. */
+
+.repo-subnav {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  flex-wrap: wrap;
+  margin-bottom: 16px;
+}
+.repo-subnav .branches { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; }
+.repo-subnav .branches .icon { color: var(--dim); }
+.clone-box {
+  background: var(--pre-bg);
+  border: 1px solid var(--line);
+  border-radius: 6px;
+  padding: 5px 10px;
+  display: inline-flex;
+  gap: 8px;
+  align-items: center;
+  font-size: 12px;
+  max-width: 100%;
+  overflow: hidden;
+  color: var(--dim);
+  font-family: inherit;
+  cursor: pointer;
+}
+.clone-box:hover { border-color: var(--acc); color: var(--fg); }
+.clone-box code { color: inherit; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.clone-box .done-icon { display: none; }
+.clone-box.copied { color: var(--add); border-color: var(--add); }
+.clone-box.copied .copy-icon { display: none; }
+.clone-box.copied .done-icon { display: inline-flex; }
+.feeds { margin-left: auto; color: var(--dim); font-size: 12px; display: flex; gap: 10px; align-items: center; }
+.feeds a { color: var(--dim); }
+.feeds a:hover { color: var(--fg); }
+
+/* ---------------------------------------------------------------- tables */
+
+table { width: 100%; border-collapse: collapse; }
+td, th { text-align: left; padding: 8px 16px 8px 0; vertical-align: middle; }
+th { color: var(--dim); font-weight: 400; font-size: 12px; }
+tr:hover td { background: var(--surface); }
+
+.desc { color: var(--dim); }
+.num { text-align: right; white-space: nowrap; }
+
+/* ------------------------------------------------------------------ .box:
+   the card wrapper used around file/ref/log tables and the README. */
+
+.box {
+  border: 1px solid var(--line);
+  border-radius: 6px;
+  overflow: hidden;
+  margin-bottom: 16px;
+}
+.box table { margin: 0; }
+.box td, .box th { padding-left: 16px; }
+.box tr { border-top: 1px solid var(--line); }
+.box tr:first-child { border-top: 0; }
+.box thead th {
+  background: var(--surface);
+  border-bottom: 1px solid var(--line);
+  padding-top: 10px;
+  padding-bottom: 10px;
+}
+.box thead tr:hover td { background: none; }
+
+/* file tree: type icon, name, right-aligned size.
+   The icon is display:block so it isn't treated as inline text — as an inline
+   element an SVG sits on the baseline and reserves descender space beneath it,
+   which is what pushed it below the filename. The cell centres it instead. */
+.tree td.mode { width: 1%; padding-right: 0; color: var(--dim); }
+.tree td.mode .icon { display: block; }
+.tree td.num { width: 1%; color: var(--dim); font-size: 12px; }
+.tree tbody a { color: var(--fg); }
+.tree tbody a:hover { color: var(--acc); }
+.box-header {
+  background: var(--surface);
+  border-bottom: 1px solid var(--line);
+  padding: 10px 16px;
+  font-size: 12px;
+  color: var(--dim);
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+/* --------------------------------------------------------------- pills:
+   commit hashes, branch names — small rounded chips on an inset bg. */
+
+.pill {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  background: var(--pre-bg);
+  border: 1px solid var(--line);
+  border-radius: 6px;
+  padding: 2px 8px;
+  font-size: 12px;
+  color: var(--dim);
+}
+a.pill:hover { text-decoration: none; border-color: var(--acc); color: var(--fg); }
+a.pill.on { color: var(--fg); border-color: var(--acc); }
+
+/* ----------------------------------------------------------------- diff */
+
+pre {
+  background: var(--pre-bg);
+  border: 1px solid var(--line);
+  padding: 12px;
+  overflow: auto;
+  border-radius: 6px;
+}
+.add { color: var(--add); }
+.del { color: var(--del); }
+.hunk { color: var(--hunk); }
+
+/* --------------------------------------------------------------- readme */
+
+.readme .box-header { color: var(--fg); font-weight: 600; }
+.readme .body { padding: 24px 32px; }
+.readme h1, .readme h2, .readme h3, .readme h4 { color: var(--fg); margin: 24px 0 12px; }
+.readme h1 { border-bottom: 1px solid var(--line); padding-bottom: .3em; font-size: 26px; }
+.readme h2 { border-bottom: 1px solid var(--line); padding-bottom: .3em; font-size: 20px; }
+.readme h1:first-child, .readme h2:first-child { margin-top: 0; }
+.readme p { margin: 12px 0; }
+.readme ul { margin: 12px 0; padding-left: 22px; }
+.readme li { margin: 4px 0; }
+.readme code { background: var(--surface); padding: 2px 6px; border-radius: 6px; font-size: 85%; }
+.readme pre { background: var(--pre-bg); }
+.readme pre code { background: none; padding: 0; font-size: 100%; }
+.license-line { color: var(--dim); font-size: 12px; margin: 0 0 16px; }
+
+/* ------------------------------------------------------------- repo list
+
+   Real <table> markup, restyled as a stack of bordered cards via CSS grid
+   — keeps it a semantic table while looking like GitHub's repo list. */
+
+#repo-list { display: block; }
+#repo-list thead { display: none; }
+#repo-list tbody, #repo-list tr { display: block; }
+#repo-list tr {
+  border: 1px solid var(--line);
+  border-radius: 6px;
+  padding: 16px;
+  margin-bottom: 12px;
+  display: grid;
+  grid-template-columns: 1fr auto;
+  gap: 4px 16px;
+}
+#repo-list tr:hover td { background: none; }
+#repo-list td { padding: 0; border: 0; }
+#repo-list td:nth-child(1) { grid-row: 1; font-size: 16px; font-weight: 600; }
+#repo-list td:nth-child(2) { grid-column: 1 / 2; grid-row: 2; color: var(--dim); font-size: 13px; }
+#repo-list td:nth-child(3) { grid-column: 2 / 3; grid-row: 1; color: var(--dim); font-size: 12px; white-space: nowrap; }
+
+/* ------------------------------------------------------------------ log */
+
+.log-date {
+  padding: 10px 16px;
+  background: var(--surface);
+  color: var(--dim);
+  font-size: 12px;
+  border-top: 1px solid var(--line);
+}
+.log-date:first-child { border-top: 0; }
+.box table td.subject { font-weight: 500; }
+
+/* --------------------------------------------------------------- footer */
+
+footer {
+  color: var(--dim);
+  border-top: 1px solid var(--line);
+  margin-top: 40px;
+  padding: 24px 0;
+  font-size: 12px;
+}
+footer .inner { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
+
+.theme-switch { display: inline-flex; align-items: center; gap: 6px; }
+.theme-switch select {
+  background: var(--surface);
+  color: var(--fg);
+  border: 1px solid var(--line);
+  border-radius: 6px;
+  padding: 3px 6px;
+  font: inherit;
+  font-size: 12px;
+}
+
+@media (max-width: 720px) {
+  .inner { padding: 0 16px; }
+  .feeds { margin-left: 0; }
+}
diff --git a/deploy.sh b/deploy.sh
new file mode 100755
index 0000000..28056c7
--- /dev/null
+++ b/deploy.sh
@@ -0,0 +1,151 @@
+#!/usr/bin/env bash
+# One-time deploy of foxygit, replacing stagit on git.kristoffersson.info.
+#
+# Run as root from anywhere:
+#   sudo bash /home/mrfox/foxygit/deploy.sh
+#
+# What it does:
+#   1. Copies index.php, inc/, views/, assets/ and themes/ to /var/www/foxygit/
+#   2. Installs a dedicated php-fpm pool running as the `git` user (the bare
+#      repos at /var/git/repos are 0700 git:git — the default www-data pool
+#      can't read them, so this needs its own pool rather than loosening
+#      those permissions).
+#   3. Swaps the git.kristoffersson.info block in /etc/caddy/Caddyfile from
+#      static-file-serving (stagit's output) to php_fastcgi against the new
+#      pool. A timestamped backup of the Caddyfile is kept.
+#   4. Reloads php-fpm and Caddy.
+#
+# Does NOT touch /var/git/repos, does NOT delete stagit or its generated
+# HTML in /var/www/git, and does NOT touch the post-receive hooks that
+# regenerate it — so stagit's output is simply orphaned (Caddy stops
+# serving it) rather than destroyed. See the printed notes at the end for
+# how to retire it once you're happy with foxygit.
+
+set -euo pipefail
+
+if [ "$(id -u)" -ne 0 ]; then
+    echo "run as root: sudo bash $0" >&2
+    exit 1
+fi
+
+SRC_DIR="/home/mrfox/foxygit"
+WEB_ROOT="/var/www/foxygit"
+POOL_FILE="/etc/php/8.4/fpm/pool.d/foxygit.conf"
+SOCK="/run/php/foxygit.sock"
+CADDYFILE="/etc/caddy/Caddyfile"
+
+echo "==> 1/5  web root: $WEB_ROOT"
+mkdir -p "$WEB_ROOT"
+install -m 0644 -o root -g root "$SRC_DIR/index.php" "$WEB_ROOT/index.php"
+# wipe+recopy so files removed from the source don't linger on the server
+for d in inc views assets themes; do
+    rm -rf "${WEB_ROOT:?}/$d"
+    cp -r "$SRC_DIR/$d" "$WEB_ROOT/$d"
+done
+chown -R root:root "$WEB_ROOT"
+find "$WEB_ROOT" -type d -exec chmod 0755 {} +
+find "$WEB_ROOT" -type f -exec chmod 0644 {} +
+
+echo "==> 2/5  php-fpm pool: $POOL_FILE"
+cat > "$POOL_FILE" <<EOF
+[foxygit]
+user = git
+group = git
+listen = $SOCK
+listen.owner = www-data
+listen.group = www-data
+listen.mode = 0660
+pm = ondemand
+pm.max_children = 4
+pm.process_idle_timeout = 10s
+EOF
+
+echo "==> 3/5  testing php-fpm config"
+php-fpm8.4 -t
+
+echo "==> reloading php8.4-fpm"
+systemctl reload php8.4-fpm
+
+if [ ! -S "$SOCK" ]; then
+    echo "WARNING: $SOCK did not appear after reload, restarting php8.4-fpm instead" >&2
+    systemctl restart php8.4-fpm
+fi
+
+echo "==> 4/5  updating Caddyfile ($CADDYFILE)"
+
+python3 - "$CADDYFILE" <<'PYEOF'
+import sys
+
+path = sys.argv[1]
+with open(path, "r", encoding="utf-8") as f:
+    content = f.read()
+
+old_block = '''git.kristoffersson.info {
+\troot * /var/www/git/
+\tfile_server
+\tencode gzip zstd
+
+\theader {
+\t\tStrict-Transport-Security "max-age=31536000; includeSubDomains"
+\t\tX-Content-Type-Options "nosniff"
+\t\tX-Frame-Options "DENY"
+\t\t-Server
+\t}
+
+\tlog {
+\t\toutput file /var/log/caddy/git.kristoffersson.info.log
+\t}
+}'''
+
+new_block = '''git.kristoffersson.info {
+\troot * /var/www/foxygit/
+\tphp_fastcgi unix//run/php/foxygit.sock
+\tfile_server
+\tencode gzip zstd
+
+\theader {
+\t\tStrict-Transport-Security "max-age=31536000; includeSubDomains"
+\t\tX-Content-Type-Options "nosniff"
+\t\tX-Frame-Options "DENY"
+\t\t-Server
+\t}
+
+\tlog {
+\t\toutput file /var/log/caddy/git.kristoffersson.info.log
+\t}
+}'''
+
+if new_block in content:
+    print("    already up to date, nothing to change")
+    sys.exit(0)
+
+if old_block not in content:
+    print("ERROR: expected git.kristoffersson.info block not found verbatim in "
+          + path + " -- Caddyfile has changed since this script was written, "
+          + "edit it by hand instead. No changes made.", file=sys.stderr)
+    sys.exit(1)
+
+import subprocess, datetime
+backup = path + ".bak." + datetime.datetime.now().strftime("%Y%m%d%H%M%S")
+subprocess.run(["cp", path, backup], check=True)
+print("    backup saved to " + backup)
+
+content = content.replace(old_block, new_block, 1)
+with open(path, "w", encoding="utf-8") as f:
+    f.write(content)
+print("    block replaced")
+PYEOF
+
+echo "==> validating Caddy config"
+caddy validate --config "$CADDYFILE"
+
+echo "==> 5/5  reloading caddy"
+systemctl reload caddy
+
+cat <<'EOF'
+
+Done. https://git.kristoffersson.info should now be served by foxygit.
+
+If stagit is still installed, it's just orphaned now (Caddy no longer
+serves /var/www/git) -- see retire-stagit.sh to remove it fully.
+EOF
diff --git a/inc/config.example.php b/inc/config.example.php
new file mode 100644
index 0000000..757815a
--- /dev/null
+++ b/inc/config.example.php
@@ -0,0 +1,16 @@
+<?php
+declare(strict_types=1);
+
+// Copy this file to config.php and fill in your real values. config.php is
+// gitignored — it never gets committed, so your domain and server paths
+// don't end up in a public repo (or its history, which git never forgets).
+
+const REPO_BASE      = '/var/git/repos';   // directory holding your *.git bare repos
+const LOG_COUNT      = 50;                  // commits shown per log page
+const SITE_NAME      = 'foxygit';
+const CLONE_BASE     = 'https://git.example.com/repos/'; // shown as `clone: <CLONE_BASE><repo>.git`; leave '' to hide — matches the anonymous-clone route from server/setup-anon-clone.sh
+const MAX_BLOB_BYTES = 1_000_000;           // files bigger than this get a "too large" notice instead of being dumped inline
+const MAX_DIFF_BYTES = 2_000_000;           // commit diffs bigger than this get truncated
+const ATOM_COUNT     = 100;                 // commits included in the atom feed
+const THEME_DEFAULT  = 'foxygit-dark';      // theme used when nothing else is requested
+const THEME_DIR      = __DIR__ . '/../themes'; // one CSS custom-property file per theme; drop a new one in to add it
diff --git a/inc/functions.php b/inc/functions.php
new file mode 100644
index 0000000..044effb
--- /dev/null
+++ b/inc/functions.php
@@ -0,0 +1,402 @@
+<?php
+declare(strict_types=1);
+
+/*
+ * All the "content" logic: talking to git, validating input, and turning
+ * raw git output into plain PHP arrays. Nothing in this file prints HTML —
+ * that's what views/ is for. The one exception is h(), colorize_diff() and
+ * markdown_to_html(), which are pure string->string transforms (same
+ * category as htmlspecialchars() itself) rather than page markup.
+ */
+
+function h(string $s): string {
+    return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
+}
+
+/** Every *.css file in THEME_DIR is a theme (just CSS custom properties — see themes/foxygit-dark.css). */
+function available_themes(): array {
+    $out = [];
+    foreach (glob(THEME_DIR . '/*.css') ?: [] as $f) {
+        $out[] = basename($f, '.css');
+    }
+    sort($out);
+    return $out !== [] ? $out : [THEME_DEFAULT];
+}
+
+function current_theme(): string {
+    $themes    = available_themes();
+    $requested = $_GET['theme'] ?? $_COOKIE['foxygit_theme'] ?? THEME_DEFAULT;
+    if (is_string($requested) && in_array($requested, $themes, true)) return $requested;
+    return in_array(THEME_DEFAULT, $themes, true) ? THEME_DEFAULT : $themes[0];
+}
+
+/** A theme is "light" if its name ends in -light; everything else counts as dark.
+ *  Purely a naming convention — it's what pairs foxygit-dark with foxygit-light. */
+function theme_is_dark(string $theme): bool {
+    return !str_ends_with($theme, '-light');
+}
+
+/** The opposite-mode sibling of a theme (foxygit-dark <-> foxygit-light), or
+ *  null when the theme has no counterpart on disk — the sun/moon toggle hides
+ *  itself in that case rather than linking somewhere that doesn't exist. */
+function theme_counterpart(string $theme): ?string {
+    if (str_ends_with($theme, '-dark')) {
+        $alt = substr($theme, 0, -strlen('-dark')) . '-light';
+    } elseif (str_ends_with($theme, '-light')) {
+        $alt = substr($theme, 0, -strlen('-light')) . '-dark';
+    } else {
+        return null;
+    }
+    return in_array($alt, available_themes(), true) ? $alt : null;
+}
+
+/** Rebuild the current query string with $overrides applied — lets the theme
+ *  controls switch theme without losing which repo/view/ref you're looking at. */
+function current_query(array $overrides = []): string {
+    $params = array_filter($_GET, 'is_string');
+    return '?' . http_build_query(array_merge($params, $overrides));
+}
+
+/** Run git inside $repo with an argument array. Each arg is shell-escaped.
+ *  Line-based: use for commands whose output you want to read as text lines
+ *  (log, refs, ls-tree, cat-file -s, ...). Not binary-safe — see git_bytes(). */
+function git(string $repo, array $args): array {
+    $cmd = 'git -c core.quotepath=false -C ' . escapeshellarg($repo);
+    foreach ($args as $a) {
+        $cmd .= ' ' . escapeshellarg($a);
+    }
+    exec($cmd . ' 2>/dev/null', $out);
+    return $out;
+}
+
+function git_raw(string $repo, array $args): string {
+    return implode("\n", git($repo, $args));
+}
+
+/** Binary-safe capture of git's stdout, byte for byte (no line splitting/
+ *  rejoining). Use for blob content and raw downloads — exec() above would
+ *  mangle trailing newlines and can misrepresent binary content. */
+function git_bytes(string $repo, array $args): string {
+    $cmd = 'git -c core.quotepath=false -C ' . escapeshellarg($repo);
+    foreach ($args as $a) {
+        $cmd .= ' ' . escapeshellarg($a);
+    }
+    $proc = proc_open($cmd, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
+    if (!is_resource($proc)) return '';
+    $data = stream_get_contents($pipes[1]);
+    fclose($pipes[1]);
+    fclose($pipes[2]);
+    proc_close($proc);
+    return $data === false ? '' : $data;
+}
+
+/** Validate + resolve a repo name to an absolute path INSIDE REPO_BASE. */
+function resolve_repo(?string $name): ?string {
+    if ($name === null || !preg_match('~^[A-Za-z0-9._-]+$~', $name)) {
+        return null;                              // reject junk / traversal chars
+    }
+    $base = realpath(REPO_BASE);
+    $path = realpath(REPO_BASE . '/' . $name);
+    if ($base === false || $path === false) return null;
+    if (strncmp($path, $base . '/', strlen($base) + 1) !== 0) return null; // must be under base
+    if (!is_dir($path)) return null;
+    if (git($path, ['rev-parse', '--git-dir']) === []) return null;        // must be a git repo
+    return $path;
+}
+
+/** Accept only a plausible ref/path so we never feed dangerous junk to git.
+ *  Blocks: empty, overlong, a leading '-' (which git would read as a flag
+ *  rather than a revision/path — the actual injection risk), '..' (path
+ *  traversal) and raw control characters. Otherwise permissive on purpose:
+ *  real filenames use plenty of punctuation and non-ASCII characters (e.g.
+ *  åäö), and an ASCII-only whitelist would just make those files unreachable. */
+function safe_ref(string $ref): bool {
+    return $ref !== ''
+        && strlen($ref) <= 200
+        && $ref[0] !== '-'
+        && strpos($ref, '..') === false
+        && !preg_match('~[\x00-\x1f]~', $ref);
+}
+
+/** Last-activity time for a repo without shelling out to git — reads mtimes
+ *  of HEAD / the branch it points at / packed-refs. Used for the repo index
+ *  so listing many repos doesn't spawn a `git log` per repo on every hit. */
+function repo_last_activity(string $path): int {
+    $t = [@filemtime($path . '/HEAD') ?: 0, @filemtime($path . '/packed-refs') ?: 0];
+    $head = @file_get_contents($path . '/HEAD');
+    if ($head !== false && preg_match('~^ref:\s*(\S+)~', trim($head), $m)) {
+        $t[] = @filemtime($path . '/' . $m[1]) ?: 0;
+    }
+    return max($t);
+}
+
+function list_repos(): array {
+    $out = [];
+    foreach (glob(REPO_BASE . '/*') ?: [] as $p) {
+        if (!is_dir($p)) continue;
+        if (git($p, ['rev-parse', '--git-dir']) === []) continue;
+        $name = basename($p);
+        $desc = @file_get_contents($p . '/description');
+        $desc = ($desc !== false && strpos($desc, 'Unnamed repository') === false)
+            ? trim($desc) : '';
+        $out[$name] = ['desc' => $desc, 'mtime' => repo_last_activity($p)];
+    }
+    uasort($out, fn($a, $b) => $b['mtime'] <=> $a['mtime']);
+    return $out;
+}
+
+/** Branch names for the branch pills in the repo subnav (log/tree views). */
+function list_branches(string $repo): array {
+    return git($repo, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']);
+}
+
+/** Find a root-level file matching $pattern in $ref's tree (e.g. README, LICENSE). */
+function find_root_file(string $repo, string $ref, string $pattern): ?string {
+    $out  = git($repo, ['ls-tree', '--name-only', '-z', $ref]);
+    $blob = $out[0] ?? '';
+    foreach (explode("\0", $blob) as $name) {
+        if ($name !== '' && preg_match($pattern, $name)) return $name;
+    }
+    return null;
+}
+
+/** README + LICENSE from a ref's root tree, ready to hand to views/tree.php.
+ *  README is rendered to HTML; oversized or binary READMEs are skipped (null). */
+function root_docs(string $repo, string $ref): array {
+    $out = ['readmeName' => null, 'readmeHtml' => null, 'licenseName' => null];
+
+    $out['licenseName'] = find_root_file($repo, $ref, '~^(licen[sc]e|copying)(\.(md|txt))?$~i');
+
+    $readme = find_root_file($repo, $ref, '~^readme(\.(md|markdown|mdown|mkd|txt))?$~i');
+    if ($readme === null) return $out;
+
+    $sizeOut = git($repo, ['cat-file', '-s', "$ref:$readme"]);
+    $size    = isset($sizeOut[0]) ? (int) $sizeOut[0] : PHP_INT_MAX;
+    if ($size > MAX_BLOB_BYTES) return $out;
+
+    $content = git_bytes($repo, ['show', "$ref:$readme"]);
+    if (strpos($content, "\0") !== false) return $out;   // binary, not a readable README
+
+    $out['readmeName'] = $readme;
+    $out['readmeHtml'] = markdown_to_html($content);
+    return $out;
+}
+
+/** Inline markdown: escapes first, code/links/bold/italic applied on top via
+ *  placeholders so nothing gets double-escaped and link hrefs are scheme-checked. */
+function inline_md(string $text): string {
+    $store = [];
+    $put = function (string $html) use (&$store): string {
+        $key = "\x02" . count($store) . "\x03";
+        $store[$key] = $html;
+        return $key;
+    };
+
+    $text = preg_replace_callback('~`([^`]+)`~', function ($m) use ($put) {
+        return $put('<code>' . h($m[1]) . '</code>');
+    }, $text);
+
+    $text = preg_replace_callback('~\[([^\]]+)\]\(([^)\s]+)\)~', function ($m) use ($put) {
+        $url = $m[2];
+        if (!preg_match('~^(https?://|mailto:|/|#)~i', $url)) return h($m[1]); // drop unsafe/unknown schemes
+        return $put('<a href="' . h($url) . '" rel="nofollow noopener">' . h($m[1]) . '</a>');
+    }, $text);
+
+    $text = h($text);
+    $text = preg_replace('~\*\*([^*]+)\*\*~', '<strong>$1</strong>', $text);
+    $text = preg_replace('~(?<!\*)\*([^*\n]+)\*(?!\*)~', '<em>$1</em>', $text);
+
+    return strtr($text, $store);
+}
+
+/** Small, dependency-free markdown → HTML: headings, paragraphs, "-"/"*"
+ *  lists, fenced code blocks, and the inline formatting from inline_md(). */
+function markdown_to_html(string $md): string {
+    $blocks = [];
+    $md = preg_replace_callback('~```[^\n]*\n(.*?)```~s', function ($m) use (&$blocks) {
+        $key = "\x02fence" . count($blocks) . "\x03";
+        $blocks[$key] = '<pre><code>' . h(rtrim($m[1], "\n")) . '</code></pre>';
+        return $key;
+    }, $md);
+
+    $html   = [];
+    $inList = false;
+    $para   = [];
+
+    $flushPara = function () use (&$para, &$html) {
+        if ($para) {
+            $html[] = '<p>' . inline_md(implode(' ', $para)) . '</p>';
+            $para = [];
+        }
+    };
+    $closeList = function () use (&$inList, &$html) {
+        if ($inList) { $html[] = '</ul>'; $inList = false; }
+    };
+
+    foreach (explode("\n", $md) as $line) {
+        $trim = rtrim($line);
+        if (preg_match('~^\x02fence\d+\x03$~', trim($trim))) {
+            $flushPara(); $closeList();
+            $html[] = trim($trim);
+        } elseif (trim($trim) === '') {
+            $flushPara(); $closeList();
+        } elseif (preg_match('~^(#{1,6})\s+(.*)$~', trim($trim), $m)) {
+            $flushPara(); $closeList();
+            $level  = strlen($m[1]);
+            $html[] = "<h$level>" . inline_md($m[2]) . "</h$level>";
+        } elseif (preg_match('~^[-*]\s+(.*)$~', trim($trim), $m)) {
+            $flushPara();
+            if (!$inList) { $html[] = '<ul>'; $inList = true; }
+            $html[] = '<li>' . inline_md($m[1]) . '</li>';
+        } else {
+            $para[] = trim($trim);
+        }
+    }
+    $flushPara(); $closeList();
+
+    return strtr(implode("\n", $html), $blocks);
+}
+
+function colorize_diff(string $diff): string {
+    $lines = [];
+    foreach (explode("\n", $diff) as $line) {
+        $e = h($line);
+        if ($line === '')                    { $lines[] = ''; continue; }
+        if (strpos($line, '@@') === 0)         $lines[] = '<span class="hunk">' . $e . '</span>';
+        elseif ($line[0] === '+')              $lines[] = '<span class="add">'  . $e . '</span>';
+        elseif ($line[0] === '-')              $lines[] = '<span class="del">'  . $e . '</span>';
+        else                                   $lines[] = $e;
+    }
+    return implode("\n", $lines);
+}
+
+/** Turn `%H\x1f%h\x1f%an\x1f%at\x1f%s` log lines into plain arrays for views/log.php. */
+function parse_log_lines(array $lines): array {
+    $out = [];
+    foreach ($lines as $l) {
+        [$full, $short, $an, $at, $subj] = array_pad(explode("\x1f", $l), 5, '');
+        $out[] = [
+            'hash'    => $full,
+            'short'   => $short,
+            'author'  => $an,
+            'at'      => $at !== '' ? (int) $at : null,
+            'subject' => $subj,
+        ];
+    }
+    return $out;
+}
+
+/** Group already-parsed log entries into ['date' => 'YYYY-MM-DD', 'entries' => [...]]
+ *  buckets, most recent day first, preserving each day's original commit order. */
+function group_log_by_date(array $entries): array {
+    $groups = [];
+    foreach ($entries as $e) {
+        $day = $e['at'] !== null ? date('Y-m-d', $e['at']) : '';
+        if (!isset($groups[$day])) $groups[$day] = ['date' => $day, 'entries' => []];
+        $groups[$day]['entries'][] = $e;
+    }
+    return array_values($groups);
+}
+
+/** Turn `%(refname:short)\x1f%(refname)` for-each-ref lines into plain arrays for views/refs.php. */
+function parse_refs(array $lines): array {
+    $out = [];
+    foreach ($lines as $r) {
+        [$short, $full] = array_pad(explode("\x1f", $r), 2, '');
+        $out[] = ['short' => $short, 'kind' => strpos($full, 'refs/tags/') === 0 ? 'tag' : 'branch'];
+    }
+    return $out;
+}
+
+/** Turn `ls-tree --long -z` output into plain arrays for views/tree.php. */
+function parse_tree(string $rawTree, string $path): array {
+    $entries = $rawTree === '' ? [] : explode("\0", rtrim($rawTree, "\0"));
+    $out = [];
+    foreach ($entries as $e) {
+        // <mode> <type> <object>   <size>\t<name>
+        if (!preg_match('~^(\d+)\s+(\w+)\s+[0-9a-f]+\s+(\S+)\t(.+)$~', $e, $m)) continue;
+        [, $mode, $type, $size, $name] = $m;
+        $out[] = [
+            'mode'   => $mode,
+            'isTree' => $type === 'tree',
+            'size'   => $size === '-' ? '' : $size,
+            'name'   => $name,
+            'child'  => $path === '' ? $name : "$path/$name",
+        ];
+    }
+    return $out;
+}
+
+/** Turn `%H\x1f%an\x1f%ae\x1f%at\x1f%s\x1f%b\x1e`-delimited log output into
+ *  plain arrays for views/atom.php and views/atom-tags.php. */
+function parse_atom_log(string $raw, string $tag = ''): array {
+    $out = [];
+    foreach ($raw === '' ? [] : explode("\x1e", $raw) as $rec) {
+        $rec = ltrim($rec, "\n");
+        if ($rec === '') continue;
+        [$hash, $an, $ae, $at, $subj, $body] = array_pad(explode("\x1f", $rec, 6), 6, '');
+        $out[] = [
+            'hash' => $hash, 'author' => $an, 'email' => $ae,
+            'at' => (int) $at, 'subject' => $subj, 'body' => $body, 'tag' => $tag,
+        ];
+    }
+    return $out;
+}
+
+const ATOM_LOG_FORMAT = '%H%x1f%an%x1f%ae%x1f%at%x1f%s%x1f%b%x1e';
+
+function atom_commit_entries(string $repo, string $ref): array {
+    return parse_atom_log(git_raw($repo, ['log', '-n', (string) ATOM_COUNT, '--pretty=format:' . ATOM_LOG_FORMAT, $ref]));
+}
+
+function atom_tag_entries(string $repo): array {
+    $out = [];
+    foreach (git($repo, ['for-each-ref', '--sort=-creatordate', '--format=%(refname:short)', 'refs/tags']) as $tag) {
+        $raw = git_raw($repo, ['log', '-1', '--pretty=format:' . ATOM_LOG_FORMAT, $tag]);
+        $entries = parse_atom_log($raw, $tag);
+        if ($entries) $out[] = $entries[0];
+    }
+    return $out;
+}
+
+/** The <content type="text"> body of one atom entry: "commit HASH\nAuthor: ...\n\nsubject\n\nbody". */
+function atom_content_text(array $entry): string {
+    $text = "commit {$entry['hash']}\n";
+    if ($entry['author'] !== '') $text .= 'Author: ' . $entry['author'] . ($entry['email'] !== '' ? " <{$entry['email']}>" : '') . "\n";
+    if ($entry['at'] > 0)        $text .= 'Date:   ' . date('D M j H:i:s Y O', $entry['at']) . "\n";
+    return $text . "\n" . $entry['subject'] . ($entry['body'] !== '' ? "\n\n" . rtrim($entry['body']) : '');
+}
+
+/** The clone URL: avoids "repo.git.git" if $repoName already ends in .git. */
+function clone_url(string $repoName): string {
+    return CLONE_BASE . (str_ends_with($repoName, '.git') ? $repoName : $repoName . '.git');
+}
+
+/** Byte count as a human-readable size: "218 B", "1.06 KB", "4.2 MB".
+ *  Takes a string because that's what ls-tree gives us, and '' (directories,
+ *  which have no size) passes straight through as ''. Trailing zeros are
+ *  trimmed so round numbers read "1 KB", not "1.00 KB". */
+function format_size(string $bytes): string {
+    if ($bytes === '' || !ctype_digit($bytes)) return '';
+    $n = (int) $bytes;
+    if ($n < 1024) return $n . ' B';
+
+    $units = ['KB', 'MB', 'GB', 'TB', 'PB'];
+    $i = -1;
+    $v = (float) $n;
+    do {
+        $v /= 1024;
+        $i++;
+        // round first: 1048575 B is 1023.999 KB, which would print as "1024 KB"
+        // once rounded to 2 decimals -- step up a unit instead.
+    } while (round($v, 2) >= 1024 && $i < count($units) - 1);
+
+    return rtrim(rtrim(number_format($v, 2, '.', ''), '0'), '.') . ' ' . $units[$i];
+}
+
+/** Repo name as shown to a reader. Bare repos are directories called `name.git`,
+ *  but that suffix is just noise in headings and listings — so it's dropped for
+ *  display only. URLs and clone_url() keep using the real directory name. */
+function repo_display_name(string $repoName): string {
+    return str_ends_with($repoName, '.git') ? substr($repoName, 0, -strlen('.git')) : $repoName;
+}
diff --git a/inc/render.php b/inc/render.php
new file mode 100644
index 0000000..c69bad2
--- /dev/null
+++ b/inc/render.php
@@ -0,0 +1,10 @@
+<?php
+declare(strict_types=1);
+
+/** The only bridge between index.php (content) and views/ (markup): extracts
+ *  $vars into local scope and includes the template. Nothing else in this
+ *  file — the boundary itself is meant to stay this small. */
+function render(string $view, array $vars = []): void {
+    extract($vars, EXTR_SKIP);
+    require __DIR__ . '/../views/' . $view . '.php';
+}
diff --git a/index.php b/index.php
new file mode 100644
index 0000000..76377c6
--- /dev/null
+++ b/index.php
@@ -0,0 +1,232 @@
+<?php
+declare(strict_types=1);
+
+/*
+ * foxygit — a tiny read-only git web frontend. No database, no deps.
+ *
+ * Reads bare repos by shelling out to the `git` binary. Push/pull is NOT
+ * handled here — that stays git-over-SSH on your bare repos (see server/).
+ * This is only the read-only "shop window", like stagit or cgit.
+ *
+ * This file is the router only: it resolves input, asks inc/functions.php
+ * to talk to git, and hands plain-array results to views/*.php to render.
+ * No HTML lives here — see views/ for that, and assets/+themes/ for CSS.
+ *
+ * Deploy: point php-fpm + Caddy at this file, set REPO_BASE in inc/config.php,
+ * make sure the php-fpm pool user can READ the bare repos (see deploy.sh).
+ */
+
+require __DIR__ . '/inc/config.php';
+require __DIR__ . '/inc/functions.php';
+require __DIR__ . '/inc/render.php';
+
+/* ---------------------------------------------------------------- routing */
+
+$repoName = $_GET['r'] ?? null;
+$repo     = $repoName !== null ? resolve_repo($repoName) : null;
+// A repo with no explicit view lands on Files (tree), the way GitHub opens on Code.
+$action   = $_GET['a'] ?? ($repoName !== null ? 'tree' : 'index');
+
+if ($repoName !== null && $repo === null) {
+    http_response_code(404);
+    $action = 'notfound';
+}
+
+$themes = available_themes();
+$theme  = current_theme();
+if (isset($_GET['theme']) && $_GET['theme'] === $theme) {   // explicit, valid choice -> remember it
+    setcookie('foxygit_theme', $theme, [
+        'expires' => time() + 31536000, 'path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax',
+    ]);
+}
+
+/* ----------------------------------------------------------------- views */
+
+if ($action === 'notfound') {
+    render('partials/head', ['title' => '404', 'theme' => $theme, 'repoName' => null]);
+    render('notfound');
+    render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
+    exit;
+}
+
+if ($repo === null) {                                   // repo index
+    render('partials/head', ['title' => SITE_NAME, 'theme' => $theme, 'repoName' => null]);
+    render('repo-index', ['repos' => list_repos()]);
+    render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
+    exit;
+}
+
+/* --- repo is valid & resolved from here on --- */
+
+if ($action === 'raw') {                                 // raw/plain file download — no view, just bytes
+    $ref  = $_GET['ref'] ?? 'HEAD';
+    $blob = $_GET['blob'] ?? '';
+    if (!safe_ref($ref)) $ref = 'HEAD';
+    if (!safe_ref($blob)) { http_response_code(400); exit('Invalid path.'); }
+
+    $spec    = $ref . ':' . $blob;
+    $sizeOut = git($repo, ['cat-file', '-s', $spec]);
+    if (!isset($sizeOut[0])) { http_response_code(404); exit('No such file.'); }
+
+    $content = git_bytes($repo, ['show', $spec]);
+    $mime = 'application/octet-stream';
+    if (function_exists('finfo_open')) {
+        $fi = finfo_open(FILEINFO_MIME_TYPE);
+        if ($fi !== false) {
+            $detected = finfo_buffer($fi, $content);
+            finfo_close($fi);
+            if ($detected) $mime = $detected;
+        }
+    }
+    header('Content-Type: ' . $mime);
+    header('Content-Length: ' . (string) strlen($content));
+    header('Content-Disposition: inline; filename="' . basename($blob) . '"');
+    header('X-Content-Type-Options: nosniff');
+    echo $content;
+    exit;
+}
+
+if ($action === 'atom' || $action === 'atom-tags') {
+    header('Content-Type: application/atom+xml; charset=utf-8');
+    if ($action === 'atom') {
+        $ref = $_GET['ref'] ?? 'HEAD';
+        if (!safe_ref($ref)) $ref = 'HEAD';
+        render('atom', ['repoName' => $repoName, 'entries' => atom_commit_entries($repo, $ref)]);
+    } else {
+        render('atom-tags', ['repoName' => $repoName, 'entries' => atom_tag_entries($repo)]);
+    }
+    exit;
+}
+
+if ($action === 'commit') {
+    $hash = $_GET['h'] ?? '';
+    render('partials/head', [
+        'title' => repo_display_name($repoName) . ' · commit', 'theme' => $theme, 'repoName' => $repoName, 'tab' => 'log',
+    ]);
+
+    if (!preg_match('~^[0-9a-f]{4,64}$~i', $hash)) {    // only hex object ids
+        http_response_code(400);
+        render('partials/error', ['message' => 'Invalid commit id.']);
+        render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
+        exit;
+    }
+
+    $diffRaw   = git_raw($repo, ['show', '--stat', '-p', '--pretty=fuller', $hash]);
+    $truncated = strlen($diffRaw) > MAX_DIFF_BYTES;
+    if ($truncated) $diffRaw = substr($diffRaw, 0, MAX_DIFF_BYTES);
+
+    render('commit', ['diff' => colorize_diff($diffRaw), 'truncated' => $truncated]);
+    render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
+    exit;
+}
+
+if ($action === 'refs') {
+    // for-each-ref's --format doesn't understand pretty-format's %xNN hex escapes (only
+    // `git log`/`show --pretty=format:` do) -- needs a real \x1f byte in the PHP string itself.
+    $refs = parse_refs(git($repo, ['for-each-ref', '--sort=-creatordate',
+        "--format=%(refname:short)\x1f%(refname)", 'refs/heads', 'refs/tags']));
+
+    render('partials/head', [
+        'title' => repo_display_name($repoName) . ' · refs', 'theme' => $theme, 'repoName' => $repoName, 'tab' => 'refs',
+    ]);
+    render('partials/repo-subnav', [
+        'repoName' => $repoName, 'curAction' => 'refs', 'curRef' => 'HEAD', 'branches' => [],
+    ]);
+    render('refs', ['repoName' => $repoName, 'refs' => $refs]);
+    render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
+    exit;
+}
+
+if ($action === 'log') {
+    $ref = $_GET['ref'] ?? 'HEAD';
+    if (!safe_ref($ref)) $ref = 'HEAD';
+    $skip = isset($_GET['skip']) ? max(0, (int) $_GET['skip']) : 0;
+
+    $lines = git($repo, ['log', '-n', (string) (LOG_COUNT + 1), '--skip', (string) $skip,
+        '--pretty=format:%H%x1f%h%x1f%an%x1f%at%x1f%s', $ref]);
+    $hasMore = count($lines) > LOG_COUNT;
+    $entries = parse_log_lines(array_slice($lines, 0, LOG_COUNT));
+
+    render('partials/head', [
+        'title' => repo_display_name($repoName) . ' · commits', 'theme' => $theme, 'repoName' => $repoName, 'tab' => 'log',
+    ]);
+    render('partials/repo-subnav', [
+        'repoName' => $repoName, 'curAction' => 'log', 'curRef' => $ref, 'branches' => list_branches($repo),
+    ]);
+    render('log', [
+        'repoName' => $repoName, 'ref' => $ref, 'skip' => $skip,
+        'dateGroups' => group_log_by_date($entries), 'hasMore' => $hasMore,
+    ]);
+    render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
+    exit;
+}
+
+/* default: tree (Files) */
+$ref = $_GET['ref'] ?? 'HEAD';
+if (!safe_ref($ref)) $ref = 'HEAD';
+
+render('partials/head', [
+    'title' => repo_display_name($repoName) . ' · files', 'theme' => $theme, 'repoName' => $repoName, 'tab' => 'tree',
+]);
+render('partials/repo-subnav', [
+    'repoName' => $repoName, 'curAction' => 'tree', 'curRef' => $ref, 'branches' => list_branches($repo),
+]);
+
+if (isset($_GET['blob'])) {                             // single file contents
+    $blob = $_GET['blob'];
+    if (!safe_ref($blob)) {
+        http_response_code(400);
+        render('partials/error', ['message' => 'Invalid path.']);
+        render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
+        exit;
+    }
+    $spec    = $ref . ':' . $blob;
+    $sizeOut = git($repo, ['cat-file', '-s', $spec]);
+    if (!isset($sizeOut[0])) {
+        http_response_code(404);
+        render('partials/error', ['message' => 'No such file.']);
+        render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
+        exit;
+    }
+    $size     = (int) $sizeOut[0];
+    $tooLarge = $size > MAX_BLOB_BYTES;
+    $content  = $tooLarge ? '' : git_bytes($repo, ['show', $spec]);
+    $isBinary = !$tooLarge && strpos($content, "\0") !== false;
+
+    render('blob', [
+        'repoName' => $repoName, 'ref' => $ref, 'blob' => $blob, 'size' => $size,
+        'tooLarge' => $tooLarge, 'isBinary' => $isBinary, 'content' => $content,
+    ]);
+    render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
+    exit;
+}
+
+$path = $_GET['path'] ?? '';
+if ($path !== '' && !safe_ref($path)) $path = '';
+$treeish = $path === '' ? $ref : "$ref:$path";
+$rawTree = git_bytes($repo, ['ls-tree', '--long', '-z', $treeish]);
+
+// README/LICENSE belong to the repo root, so they only show on the top-level tree
+$docs = $path === '' ? root_docs($repo, $ref) : ['readmeName' => null, 'readmeHtml' => null, 'licenseName' => null];
+
+render('tree', [
+    'repoName' => $repoName, 'ref' => $ref, 'path' => $path,
+    'entries' => parse_tree($rawTree, $path),
+] + $docs);
+render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
+
+/*
+ * -------------------------------------------------------------- DEPLOYMENT
+ *
+ * This server's bare repos live at /var/git/repos, owned git:git, mode 0700
+ * (set up by server/setup-server.sh). php-fpm's default pool runs as
+ * www-data, which can't read them — so this needs its own pool running as
+ * the `git` user instead. See deploy.sh in this directory, which does the
+ * whole thing (pool + web root incl. inc/, views/, assets/, themes/ + Caddy
+ * block + reload):
+ *
+ *      sudo bash /home/mrfox/foxygit/deploy.sh
+ *
+ * Make sure exec() and proc_open() are not in php.ini's disable_functions
+ * (they aren't by default on Debian). That's the whole thing.
+ */
diff --git a/install.sh b/install.sh
new file mode 100755
index 0000000..2af491d
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,46 @@
+#!/usr/bin/env bash
+# Full install, from zero: git hosting over SSH + foxygit as the web viewer.
+#
+# Run as root:
+#   sudo bash /home/mrfox/foxygit/install.sh
+#
+# Runs, in order:
+#   1. server/setup-server.sh -- creates the `git` user, bare-repo storage
+#      at /var/git/repos, and the self-serve `create` command over SSH.
+#   2. deploy.sh -- php-fpm pool (running as `git`, so it can read the bare
+#      repos) + the git.kristoffersson.info Caddy block + reload.
+#
+# Both scripts are idempotent, so this is also safe to re-run (e.g. after
+# editing index.php -- though for that alone, just re-copying index.php to
+# /var/www/foxygit/ is enough, no reload needed).
+
+set -euo pipefail
+
+if [ "$(id -u)" -ne 0 ]; then
+    echo "run as root: sudo bash $0" >&2
+    exit 1
+fi
+
+ROOT="$(cd "$(dirname "$0")" && pwd)"
+
+echo "############################################"
+echo "# 1/2  git hosting (SSH server)"
+echo "############################################"
+bash "$ROOT/server/setup-server.sh"
+
+echo
+echo "############################################"
+echo "# 2/2  foxygit (web viewer)"
+echo "############################################"
+bash "$ROOT/deploy.sh"
+
+cat <<'EOF'
+
+############################################
+All done.
+
+  - Add SSH keys for people who push:  sudo bash server/add-key.sh
+  - Create a repo (as a keyholder):    ssh git@<host> create <reponame>
+  - Browse:                            https://git.kristoffersson.info/
+############################################
+EOF
diff --git a/retire-stagit.sh b/retire-stagit.sh
new file mode 100755
index 0000000..e9eeb95
--- /dev/null
+++ b/retire-stagit.sh
@@ -0,0 +1,73 @@
+#!/usr/bin/env bash
+# Retires stagit now that foxygit is live and serving git.kristoffersson.info.
+#
+# Run as root:
+#   sudo bash /home/mrfox/foxygit/retire-stagit.sh
+#
+# What it does:
+#   1. Removes the post-receive hook (the template used for new repos, plus
+#      every existing repo's own copy) that regenerated stagit's static HTML
+#      on every push. Push/pull itself is untouched: git doesn't need a
+#      post-receive hook to function at all, this hook only ever triggered
+#      stagit regeneration.
+#   2. Deletes the old static output at /var/www/git (Caddy's
+#      git.kristoffersson.info block already points at /var/www/foxygit
+#      instead, since deploy.sh ran, so nothing serves this anymore).
+#   3. Removes the stagit/stagit-index binaries from /usr/local/bin.
+#
+# Does NOT touch /home/mrfox/stagit (the source checkout) or anything in it
+# -- add-key.sh, setup-server.sh, git-shell-commands/create and the rest of
+# the SSH / push-pull tooling are left exactly as they are.
+
+set -euo pipefail
+
+if [ "$(id -u)" -ne 0 ]; then
+    echo "run as root: sudo bash $0" >&2
+    exit 1
+fi
+
+TEMPLATE_HOOK="/var/git/templates/hooks/post-receive"
+REPOS_DIR="/var/git/repos"
+HTML_DIR="/var/www/git"
+
+echo "==> 1/3  removing post-receive hooks (stagit regeneration trigger)"
+if [ -f "$TEMPLATE_HOOK" ]; then
+    rm -f "$TEMPLATE_HOOK"
+    echo "    removed $TEMPLATE_HOOK (new repos won't get it anymore)"
+else
+    echo "    $TEMPLATE_HOOK already gone"
+fi
+if [ -d "$REPOS_DIR" ]; then
+    found=0
+    while IFS= read -r -d '' hook; do
+        rm -f "$hook"
+        echo "    removed $hook"
+        found=1
+    done < <(find "$REPOS_DIR" -maxdepth 3 -path '*/hooks/post-receive' -print0)
+    [ "$found" = "0" ] && echo "    no per-repo post-receive hooks found"
+else
+    echo "    $REPOS_DIR not found, skipping"
+fi
+
+echo "==> 2/3  removing old static output: $HTML_DIR"
+if [ -d "$HTML_DIR" ]; then
+    rm -rf "$HTML_DIR"
+    echo "    removed"
+else
+    echo "    already gone"
+fi
+
+echo "==> 3/3  removing stagit binaries"
+rm -fv /usr/local/bin/stagit /usr/local/bin/stagit-index
+
+cat <<'EOF'
+
+Done. Stagit is fully retired:
+  - no more regeneration on push (hooks removed, template + per-repo copies)
+  - old static HTML removed (/var/www/git)
+  - stagit/stagit-index binaries removed
+
+/home/mrfox/stagit (source checkout, add-key.sh, setup-server.sh,
+git-shell-commands/create) was left untouched -- your SSH/push/pull
+tooling still works exactly as before.
+EOF
diff --git a/server/add-key.sh b/server/add-key.sh
new file mode 100755
index 0000000..ba835d3
--- /dev/null
+++ b/server/add-key.sh
@@ -0,0 +1,75 @@
+#!/usr/bin/env bash
+# Genererar ett nytt SSH-nyckelpar för en person, visar den publika nyckeln
+# för kopiering, och lägger in den i git-användarens authorized_keys.
+#
+# Kör som root, från repo-roten på servern:
+#   sudo ./server/add-key.sh
+set -euo pipefail
+
+GIT_USER="${GIT_USER:-git}"
+GIT_HOME="${GIT_HOME:-/var/git}"
+SSH_DIR="$GIT_HOME/.ssh"
+AUTH_KEYS="$SSH_DIR/authorized_keys"
+
+if [ "$(id -u)" -ne 0 ]; then
+	echo "kör som root (sudo ./server/add-key.sh)" >&2
+	exit 1
+fi
+
+# Store the generated keypair in the invoking (sudo) user's home, not
+# /root: root login over SSH is normally disabled, so scp'ing the key
+# down would otherwise require an extra "sudo cp" detour first.
+INVOKING_USER="${SUDO_USER:-root}"
+INVOKING_HOME="$(getent passwd "$INVOKING_USER" | cut -d: -f6)"
+KEY_STORE="${KEY_STORE:-$INVOKING_HOME/git-user-keys}"
+
+if ! id "$GIT_USER" >/dev/null 2>&1; then
+	echo "användaren '$GIT_USER' finns inte ännu." >&2
+	echo "kör server/setup-server.sh först." >&2
+	exit 1
+fi
+
+read -rp "Ange userID (namn eller e-post) för nyckeln: " userid
+if [ -z "$userid" ]; then
+	echo "userID får inte vara tomt" >&2
+	exit 1
+fi
+
+mkdir -p "$KEY_STORE"
+keyfile="$KEY_STORE/${userid//[^A-Za-z0-9._-]/_}"
+
+if [ -e "$keyfile" ]; then
+	echo "en nyckel med det namnet finns redan: $keyfile" >&2
+	exit 1
+fi
+
+ssh-keygen -t ed25519 -C "$userid" -N "" -f "$keyfile" >/dev/null
+chown "$INVOKING_USER" "$keyfile" "${keyfile}.pub"
+pubkey="$(cat "${keyfile}.pub")"
+
+echo
+echo "===== Publik nyckel ($userid) ====="
+echo "$pubkey"
+echo "===================================="
+echo
+
+mkdir -p "$SSH_DIR"
+touch "$AUTH_KEYS"
+
+if grep -qF "$pubkey" "$AUTH_KEYS"; then
+	echo "nyckeln finns redan i $AUTH_KEYS, hoppar över tillägg."
+else
+	echo "$pubkey" >> "$AUTH_KEYS"
+	echo "nyckel tillagd i $AUTH_KEYS"
+fi
+
+chown -R "$GIT_USER":"$GIT_USER" "$SSH_DIR"
+chmod 700 "$SSH_DIR"
+chmod 600 "$AUTH_KEYS"
+
+echo
+echo "Privat nyckel sparad på servern: ${keyfile} (ägs av $INVOKING_USER)"
+echo "Hämta ner den från din lokala dator:"
+echo "  scp ${INVOKING_USER}@<server>:${keyfile} ~/.ssh/git_kristoffersson"
+echo "Ta sedan bort kopian på servern:"
+echo "  rm ${keyfile} ${keyfile}.pub"
diff --git a/server/fcgiwrap-git.service b/server/fcgiwrap-git.service
new file mode 100644
index 0000000..7d50261
--- /dev/null
+++ b/server/fcgiwrap-git.service
@@ -0,0 +1,14 @@
+[Unit]
+Description=fcgiwrap for git-http-backend (anonymous read-only clone)
+Requires=fcgiwrap-git.socket
+
+[Service]
+User=git
+Group=git
+ExecStart=/usr/sbin/fcgiwrap
+StandardInput=socket
+StandardError=journal
+NoNewPrivileges=true
+
+[Install]
+Also=fcgiwrap-git.socket
diff --git a/server/fcgiwrap-git.socket b/server/fcgiwrap-git.socket
new file mode 100644
index 0000000..1780901
--- /dev/null
+++ b/server/fcgiwrap-git.socket
@@ -0,0 +1,11 @@
+[Unit]
+Description=Socket for fcgiwrap (git-http-backend, runs as the git user)
+
+[Socket]
+ListenStream=/run/fcgiwrap-git.sock
+SocketUser=www-data
+SocketGroup=www-data
+SocketMode=0660
+
+[Install]
+WantedBy=sockets.target
diff --git a/server/git-shell-commands/create b/server/git-shell-commands/create
new file mode 100755
index 0000000..fd7ce20
--- /dev/null
+++ b/server/git-shell-commands/create
@@ -0,0 +1,37 @@
+#!/bin/sh
+# usage (over ssh, as the git user): ssh git@<host> create <reponame>
+#
+# Creates a new bare repo under $reposdir, initialized from $templatedir
+# (currently empty -- no hooks are installed by default; foxygit reads bare
+# repos live, no regeneration step needed). Paths below must match
+# server/setup-server.sh.
+
+reposdir="/var/git/repos"
+templatedir="/var/git/templates"
+sshhost="git.kristoffersson.info"	# must resolve unproxied (DNS-only in Cloudflare) — a
+					# proxied record doesn't forward SSH (port 22)
+
+name="$1"
+if [ -z "$name" ]; then
+	echo "usage: create <reponame>" >&2
+	exit 1
+fi
+
+name=$(basename "$name" ".git")
+case "$name" in
+	*/* | .* | -*)
+		echo "invalid repo name: $name" >&2
+		exit 1
+		;;
+esac
+
+dest="${reposdir}/${name}.git"
+if [ -e "$dest" ]; then
+	echo "repo already exists: ${name}.git" >&2
+	exit 1
+fi
+
+git init --bare --template="$templatedir" -- "$dest" >/dev/null
+
+echo "created repos/${name}.git"
+echo "clone with: git clone git@${sshhost}:repos/${name}.git"
diff --git a/server/setup-anon-clone.sh b/server/setup-anon-clone.sh
new file mode 100755
index 0000000..9352e5c
--- /dev/null
+++ b/server/setup-anon-clone.sh
@@ -0,0 +1,156 @@
+#!/usr/bin/env bash
+# Anonymous, unauthenticated read-only `git clone` over HTTPS -- the same
+# pattern GitHub/GitLab/Bitbucket use for public repos: git-http-backend
+# behind the web server, no credentials needed for fetch/clone, push stays
+# SSH-key-only (git-http-backend disables receive-pack over HTTP unless a
+# repo explicitly sets http.receivepack=true, which nothing here does).
+#
+# Since foxygit already serves every repo's full history/contents to
+# anyone who browses git.kristoffersson.info with zero auth, this doesn't
+# change what's exposed -- it just adds a second, more useful way (`git
+# clone` instead of clicking through a web UI) to get at the same data.
+# All repos under /var/git/repos become clonable (GIT_HTTP_EXPORT_ALL=1);
+# there's no per-repo opt-in.
+#
+# What it does:
+#   1. apt-get install fcgiwrap
+#   2. Installs a dedicated fcgiwrap instance (server/fcgiwrap-git.socket
+#      + .service) running as the `git` user -- same reasoning as the
+#      php-fpm pool in ../deploy.sh: /var/git/repos is 0700 git:git.
+#   3. Adds a `handle_path /repos/*` route to the git.kristoffersson.info
+#      Caddy block that proxies to git-http-backend via that socket.
+#      Backs up the Caddyfile first. Idempotent -- safe to re-run.
+#
+# Run as root:
+#   sudo bash /home/mrfox/foxygit/server/setup-anon-clone.sh
+
+set -euo pipefail
+
+if [ "$(id -u)" -ne 0 ]; then
+    echo "run as root: sudo bash $0" >&2
+    exit 1
+fi
+
+ROOT="$(cd "$(dirname "$0")" && pwd)"
+CADDYFILE="/etc/caddy/Caddyfile"
+SOCK="/run/fcgiwrap-git.sock"
+
+echo "==> 1/4  installing fcgiwrap"
+if ! command -v fcgiwrap >/dev/null 2>&1; then
+    apt-get update -qq
+    apt-get install -y fcgiwrap
+else
+    echo "    already installed"
+fi
+
+# The Debian package enables its own default fcgiwrap.socket (as www-data,
+# can't read /var/git). We don't use it -- disable so it's not just an idle
+# unused (but still exposed-to-www-data) FastCGI worker sitting around.
+if systemctl is-enabled --quiet fcgiwrap.socket 2>/dev/null; then
+    systemctl disable --now fcgiwrap.socket >/dev/null 2>&1 || true
+    echo "    disabled the default fcgiwrap.socket (unused, replaced by fcgiwrap-git)"
+fi
+
+echo "==> 2/4  installing the fcgiwrap-git service (runs as the git user)"
+install -m 0644 "$ROOT/fcgiwrap-git.socket"  /etc/systemd/system/fcgiwrap-git.socket
+install -m 0644 "$ROOT/fcgiwrap-git.service" /etc/systemd/system/fcgiwrap-git.service
+systemctl daemon-reload
+systemctl enable --now fcgiwrap-git.socket
+
+if [ ! -S "$SOCK" ]; then
+    echo "WARNING: $SOCK did not appear, check: systemctl status fcgiwrap-git.socket" >&2
+fi
+
+echo "==> 3/4  updating Caddyfile ($CADDYFILE)"
+
+python3 - "$CADDYFILE" <<'PYEOF'
+import sys, subprocess, datetime
+
+path = sys.argv[1]
+with open(path, "r", encoding="utf-8") as f:
+    content = f.read()
+
+old_block = '''git.kristoffersson.info {
+\troot * /var/www/foxygit/
+\tphp_fastcgi unix//run/php/foxygit.sock
+\tfile_server
+\tencode gzip zstd
+
+\theader {
+\t\tStrict-Transport-Security "max-age=31536000; includeSubDomains"
+\t\tX-Content-Type-Options "nosniff"
+\t\tX-Frame-Options "DENY"
+\t\t-Server
+\t}
+
+\tlog {
+\t\toutput file /var/log/caddy/git.kristoffersson.info.log
+\t}
+}'''
+
+new_block = '''git.kristoffersson.info {
+\troot * /var/www/foxygit/
+
+\thandle_path /repos/* {
+\t\treverse_proxy unix//run/fcgiwrap-git.sock {
+\t\t\ttransport fastcgi {
+\t\t\t\tenv SCRIPT_FILENAME /usr/lib/git-core/git-http-backend
+\t\t\t\tenv GIT_PROJECT_ROOT /var/git/repos
+\t\t\t\tenv GIT_HTTP_EXPORT_ALL 1
+\t\t\t\tenv PATH_INFO {http.request.uri.path}
+\t\t\t}
+\t\t}
+\t}
+
+\tphp_fastcgi unix//run/php/foxygit.sock
+\tfile_server
+\tencode gzip zstd
+
+\theader {
+\t\tStrict-Transport-Security "max-age=31536000; includeSubDomains"
+\t\tX-Content-Type-Options "nosniff"
+\t\tX-Frame-Options "DENY"
+\t\t-Server
+\t}
+
+\tlog {
+\t\toutput file /var/log/caddy/git.kristoffersson.info.log
+\t}
+}'''
+
+if new_block in content:
+    print("    already up to date, nothing to change")
+    sys.exit(0)
+
+if old_block not in content:
+    print("ERROR: expected git.kristoffersson.info block not found verbatim in "
+          + path + " -- Caddyfile has changed since this script was written, "
+          + "edit it by hand instead (add the handle_path block from this "
+          + "script's source). No changes made.", file=sys.stderr)
+    sys.exit(1)
+
+backup = path + ".bak." + datetime.datetime.now().strftime("%Y%m%d%H%M%S")
+subprocess.run(["cp", path, backup], check=True)
+print("    backup saved to " + backup)
+
+content = content.replace(old_block, new_block, 1)
+with open(path, "w", encoding="utf-8") as f:
+    f.write(content)
+print("    block replaced")
+PYEOF
+
+echo "==> validating Caddy config"
+caddy validate --config "$CADDYFILE"
+
+echo "==> 4/4  reloading caddy"
+systemctl reload caddy
+
+cat <<'EOF'
+
+Done. Try it:
+  git clone https://git.kristoffersson.info/repos/<reponame>.git
+
+No credentials needed. Push still requires an SSH key (unchanged) --
+git-http-backend only serves receive-pack (push) over HTTP if a repo's own
+config explicitly sets http.receivepack=true, which none of them do.
+EOF
diff --git a/server/setup-server.sh b/server/setup-server.sh
new file mode 100755
index 0000000..9a23674
--- /dev/null
+++ b/server/setup-server.sh
@@ -0,0 +1,72 @@
+#!/usr/bin/env bash
+# One-time server setup for git hosting over SSH: creates the dedicated git
+# user, the bare-repo storage, and the git-shell-commands (so users can
+# self-serve `create`). Independent of foxygit/stagit -- this is just the
+# SSH/push/pull side. For the web viewer, see ../deploy.sh (or run
+# ../install.sh to do both).
+#
+# Run as root, from anywhere:
+#   sudo bash /home/mrfox/foxygit/server/setup-server.sh
+#
+# Safe to re-run.
+
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")" && pwd)"
+
+GIT_USER="${GIT_USER:-git}"
+GIT_HOME="${GIT_HOME:-/var/git}"
+REPOS_DIR="$GIT_HOME/repos"
+TEMPLATE_DIR="$GIT_HOME/templates"
+SHELL_CMDS_DIR="$GIT_HOME/git-shell-commands"
+SSH_DIR="$GIT_HOME/.ssh"
+
+if [ "$(id -u)" -ne 0 ]; then
+	echo "run this as root (sudo bash $0)" >&2
+	exit 1
+fi
+
+GIT_SHELL_BIN="$(command -v git-shell || true)"
+if [ -z "$GIT_SHELL_BIN" ]; then
+	echo "git-shell not found on PATH (is git installed?)" >&2
+	exit 1
+fi
+grep -qxF "$GIT_SHELL_BIN" /etc/shells || echo "$GIT_SHELL_BIN" >> /etc/shells
+
+if ! id "$GIT_USER" >/dev/null 2>&1; then
+	useradd --create-home --home-dir "$GIT_HOME" --shell "$GIT_SHELL_BIN" "$GIT_USER"
+else
+	usermod --shell "$GIT_SHELL_BIN" "$GIT_USER"
+fi
+
+mkdir -p "$REPOS_DIR" "$TEMPLATE_DIR" "$SHELL_CMDS_DIR" "$SSH_DIR"
+
+install -m 0755 "$ROOT/git-shell-commands/create" "$SHELL_CMDS_DIR/create"
+
+touch "$SSH_DIR/authorized_keys"
+chmod 700 "$SSH_DIR"
+chmod 600 "$SSH_DIR/authorized_keys"
+
+chown -R "$GIT_USER":"$GIT_USER" "$GIT_HOME"
+
+cat <<EOF
+
+Klart.
+
+Nästa steg:
+1. Lägg till publika SSH-nycklar (server/add-key.sh, eller manuellt en per rad i):
+     $SSH_DIR/authorized_keys
+   t.ex: ssh-ed25519 AAAA... namn@example.com
+
+2. Skapa ett nytt repo (som valfri användare med nyckel i authorized_keys):
+     ssh $GIT_USER@<host> create <reponame>
+
+3. Klona:
+     git clone $GIT_USER@<host>:repos/<reponame>.git
+
+4. Servera repona med foxygit (webbläsning) -- se ../deploy.sh, eller kör
+   ../install.sh för att göra båda delarna i ett svep.
+
+Kontrollera att sshhost i server/git-shell-commands/create matchar din
+faktiska domän.
+EOF
diff --git a/themes/foxygit-dark.css b/themes/foxygit-dark.css
new file mode 100644
index 0000000..f9b3fb4
--- /dev/null
+++ b/themes/foxygit-dark.css
@@ -0,0 +1,16 @@
+/* foxygit-dark — the original look. Every theme just redefines these
+   custom properties; assets/base.css does all the actual layout/structure
+   and never has a hardcoded color in it. Copy this file to add a theme. */
+:root {
+  --bg:      #0d0f12;
+  --fg:      #c8ccd0;
+  --dim:     #6b7178;
+  --acc:     #7fd88f;
+  --line:    #20242a;
+  --surface: #14171b;
+  --pre-bg:  #0a0c0f;
+  --add:     #7fd88f;
+  --del:     #e06c75;
+  --hunk:    #61afef;
+  --font-ui: ui-monospace, "JetBrains Mono", Menlo, Consolas, monospace;
+}
diff --git a/themes/foxygit-light.css b/themes/foxygit-light.css
new file mode 100644
index 0000000..977c2c7
--- /dev/null
+++ b/themes/foxygit-light.css
@@ -0,0 +1,15 @@
+/* foxygit-light — light counterpart of foxygit-dark, so the sun/moon toggle
+   in the header has something to switch to. Same 11 tokens as every theme. */
+:root {
+  --bg:      #fbfbfa;
+  --fg:      #24282c;
+  --dim:     #6b7178;
+  --acc:     #1f7a3d;
+  --line:    #dcdfe3;
+  --surface: #f0f1f3;
+  --pre-bg:  #f6f7f8;
+  --add:     #1f7a3d;
+  --del:     #c0392b;
+  --hunk:    #1a5fb4;
+  --font-ui: ui-monospace, "JetBrains Mono", Menlo, Consolas, monospace;
+}
diff --git a/themes/github-dark.css b/themes/github-dark.css
new file mode 100644
index 0000000..e648cc0
--- /dev/null
+++ b/themes/github-dark.css
@@ -0,0 +1,15 @@
+/* github-dark — GitHub Primer dark palette. Same 11 tokens as every other
+   theme in this directory; assets/base.css does all the actual layout. */
+:root {
+  --bg:      #0d1117;
+  --fg:      #e6edf3;
+  --dim:     #8b949e;
+  --acc:     #58a6ff;
+  --line:    #30363d;
+  --surface: #161b22;
+  --pre-bg:  #010409;
+  --add:     #3fb950;
+  --del:     #f85149;
+  --hunk:    #79c0ff;
+  --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
+}
diff --git a/themes/github-light.css b/themes/github-light.css
new file mode 100644
index 0000000..e82425a
--- /dev/null
+++ b/themes/github-light.css
@@ -0,0 +1,15 @@
+/* github-light — GitHub Primer light palette. Same 11 tokens as every other
+   theme in this directory; assets/base.css does all the actual layout. */
+:root {
+  --bg:      #ffffff;
+  --fg:      #1f2328;
+  --dim:     #59636e;
+  --acc:     #0969da;
+  --line:    #d0d7de;
+  --surface: #f6f8fa;
+  --pre-bg:  #f6f8fa;
+  --add:     #1a7f37;
+  --del:     #cf222e;
+  --hunk:    #0550ae;
+  --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
+}
diff --git a/views/atom-tags.php b/views/atom-tags.php
new file mode 100644
index 0000000..490e817
--- /dev/null
+++ b/views/atom-tags.php
@@ -0,0 +1,17 @@
+<?php /** @var string $repoName @var array $entries (header + xml decl already sent by index.php) */ ?>
+<?= '<' . '?xml version="1.0" encoding="utf-8"?' . '>' ?>
+
+<feed xmlns="http://www.w3.org/2005/Atom">
+<title><?= h($repoName) ?>, tags</title>
+<updated><?= date(DATE_ATOM) ?></updated>
+<?php foreach ($entries as $e): ?>
+<entry>
+<id><?= h($e['hash']) ?></id>
+<title>[<?= h($e['tag']) ?>] <?= h($e['subject']) ?></title>
+<?php if ($e['at'] > 0): ?><updated><?= date(DATE_ATOM, $e['at']) ?></updated><?php endif; ?>
+<link rel="alternate" href="<?= h('?r=' . $repoName . '&a=commit&h=' . $e['hash']) ?>" />
+<author><name><?= h($e['author']) ?></name><?php if ($e['email'] !== ''): ?><email><?= h($e['email']) ?></email><?php endif; ?></author>
+<content type="text"><?= h(atom_content_text($e)) ?></content>
+</entry>
+<?php endforeach; ?>
+</feed>
diff --git a/views/atom.php b/views/atom.php
new file mode 100644
index 0000000..7c39643
--- /dev/null
+++ b/views/atom.php
@@ -0,0 +1,17 @@
+<?php /** @var string $repoName @var array $entries (header + xml decl already sent by index.php) */ ?>
+<?= '<' . '?xml version="1.0" encoding="utf-8"?' . '>' ?>
+
+<feed xmlns="http://www.w3.org/2005/Atom">
+<title><?= h($repoName) ?>, branch HEAD</title>
+<updated><?= date(DATE_ATOM) ?></updated>
+<?php foreach ($entries as $e): ?>
+<entry>
+<id><?= h($e['hash']) ?></id>
+<title><?= h($e['subject']) ?></title>
+<?php if ($e['at'] > 0): ?><updated><?= date(DATE_ATOM, $e['at']) ?></updated><?php endif; ?>
+<link rel="alternate" href="<?= h('?r=' . $repoName . '&a=commit&h=' . $e['hash']) ?>" />
+<author><name><?= h($e['author']) ?></name><?php if ($e['email'] !== ''): ?><email><?= h($e['email']) ?></email><?php endif; ?></author>
+<content type="text"><?= h(atom_content_text($e)) ?></content>
+</entry>
+<?php endforeach; ?>
+</feed>
diff --git a/views/blob.php b/views/blob.php
new file mode 100644
index 0000000..6540632
--- /dev/null
+++ b/views/blob.php
@@ -0,0 +1,13 @@
+<?php
+/** @var string $repoName @var string $ref @var string $blob @var int $size
+ *  @var bool $tooLarge @var bool $isBinary @var string $content */
+$rawHref = '?r=' . $repoName . '&a=raw&ref=' . $ref . '&blob=' . $blob;
+?>
+<p class="desc">/<?= h($blob) ?> · <?= h(format_size((string) $size)) ?> · <a href="<?= h($rawHref) ?>">raw</a></p>
+<?php if ($tooLarge): ?>
+<p class="desc">File too large to display inline — use the raw link above.</p>
+<?php elseif ($isBinary): ?>
+<p class="desc">Binary file — use the raw link above.</p>
+<?php else: ?>
+<pre><?= h($content) ?></pre>
+<?php endif; ?>
diff --git a/views/commit.php b/views/commit.php
new file mode 100644
index 0000000..28edc89
--- /dev/null
+++ b/views/commit.php
@@ -0,0 +1,5 @@
+<?php /** @var string $diff (already colorized HTML) @var bool $truncated */ ?>
+<pre><?= $diff ?></pre>
+<?php if ($truncated): ?>
+<p class="desc">— diff truncated, too large to show in full —</p>
+<?php endif; ?>
diff --git a/views/log.php b/views/log.php
new file mode 100644
index 0000000..c8e9674
--- /dev/null
+++ b/views/log.php
@@ -0,0 +1,29 @@
+<?php
+/** @var string $repoName @var string $ref @var int $skip
+ *  @var array $dateGroups (from group_log_by_date()) @var bool $hasMore */
+?>
+<div class="box">
+<table>
+<?php foreach ($dateGroups as $group): ?>
+  <tr><td colspan="3" class="log-date"><?= $group['date'] !== '' ? h($group['date']) : '' ?></td></tr>
+  <?php foreach ($group['entries'] as $e): ?>
+    <tr>
+      <td class="subject"><a href="?r=<?= h($repoName) ?>&a=commit&h=<?= h($e['hash']) ?>"><?= h($e['subject']) ?></a></td>
+      <td class="desc"><?= h($e['author']) ?></td>
+      <td><a class="pill" href="?r=<?= h($repoName) ?>&a=commit&h=<?= h($e['hash']) ?>"><?= h($e['short']) ?></a></td>
+    </tr>
+  <?php endforeach; ?>
+<?php endforeach; ?>
+</table>
+</div>
+
+<?php if ($skip > 0 || $hasMore): ?>
+<p class="desc">
+  <?php if ($skip > 0): ?>
+    <a href="?r=<?= h($repoName) ?>&a=log&ref=<?= h($ref) ?>&skip=<?= max(0, $skip - LOG_COUNT) ?>">← newer</a>
+  <?php endif; ?>
+  <?php if ($hasMore): ?>
+    <a href="?r=<?= h($repoName) ?>&a=log&ref=<?= h($ref) ?>&skip=<?= $skip + LOG_COUNT ?>">older →</a>
+  <?php endif; ?>
+</p>
+<?php endif; ?>
diff --git a/views/notfound.php b/views/notfound.php
new file mode 100644
index 0000000..e5b0299
--- /dev/null
+++ b/views/notfound.php
@@ -0,0 +1 @@
+<?php render('partials/error', ['message' => 'No such repository.']) ?>
diff --git a/views/partials/error.php b/views/partials/error.php
new file mode 100644
index 0000000..dbdba85
--- /dev/null
+++ b/views/partials/error.php
@@ -0,0 +1,2 @@
+<?php /** @var string $message */ ?>
+<p><?= h($message) ?></p>
diff --git a/views/partials/foot.php b/views/partials/foot.php
new file mode 100644
index 0000000..b306b0c
--- /dev/null
+++ b/views/partials/foot.php
@@ -0,0 +1,10 @@
+<?php /** @var string $theme @var string[] $themes */ ?>
+</div></main>
+<footer>
+  <div class="inner">
+    <span>foxygit · read-only · push/pull lives in your bare repos over SSH</span>
+    <?php render('partials/theme-switcher', ['theme' => $theme, 'themes' => $themes]); ?>
+  </div>
+</footer>
+<script src="assets/app.js" defer></script>
+</body></html>
diff --git a/views/partials/head.php b/views/partials/head.php
new file mode 100644
index 0000000..ee3aa35
--- /dev/null
+++ b/views/partials/head.php
@@ -0,0 +1,44 @@
+<?php
+/** @var string $title @var string $theme @var ?string $repoName
+ *  @var ?string $tab  which nav tab is current ('log'/'refs'/'tree'), null for no tab bar
+ *  Owns the whole page shell — header, tab bar and the opening <main> — so the
+ *  full-width bars and the centred column stay in one place. foot.php closes it. */
+$altTheme = theme_counterpart($theme);
+$tab = $tab ?? null;
+?>
+<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title><?= h($title) ?></title>
+<link rel="stylesheet" href="themes/<?= h($theme) ?>.css">
+<link rel="stylesheet" href="assets/base.css">
+</head><body>
+<header>
+  <div class="inner">
+    <a class="brand" href="?">
+      <svg class="icon" width="20" height="20" viewBox="0 0 16 16" aria-hidden="true"><path d="M11.28 6.78a.75.75 0 0 0-1.06-1.06L7.25 8.69 5.78 7.22a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.06 0l3.5-3.5Z"></path><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13Z"></path></svg>
+      <?= h(SITE_NAME) ?>
+    </a>
+    <?php if ($repoName): ?>
+      <span class="path">
+        / <svg class="icon" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8ZM5 12.25a.25.25 0 0 1 .25-.25h3.5a.25.25 0 0 1 .25.25v3.25a.25.25 0 0 1-.4.2l-1.45-1.087a.249.249 0 0 0-.3 0L5.4 15.7a.25.25 0 0 1-.4-.2Z"></path></svg>
+        <a href="?r=<?= h($repoName) ?>"><?= h(repo_display_name($repoName)) ?></a>
+      </span>
+    <?php endif; ?>
+    <span class="spacer"></span>
+    <?php if ($altTheme !== null): ?>
+      <a class="theme-toggle" href="<?= h(current_query(['theme' => $altTheme])) ?>"
+         title="<?= theme_is_dark($theme) ? 'Byt till ljust tema' : 'Byt till mörkt tema' ?>"
+         aria-label="<?= theme_is_dark($theme) ? 'Byt till ljust tema' : 'Byt till mörkt tema' ?>">
+        <?php if (theme_is_dark($theme)): ?>
+          <svg class="icon" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 12a4 4 0 1 1 0-8 4 4 0 0 1 0 8Zm0-1.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Zm5.657-8.157a.75.75 0 0 1 0 1.06l-1.061 1.062a.75.75 0 0 1-1.06-1.06l1.06-1.062a.75.75 0 0 1 1.06 0Zm-9.193 9.193a.75.75 0 0 1 0 1.06L3.11 13.72a.75.75 0 1 1-1.06-1.06l1.06-1.06a.75.75 0 0 1 1.06 0ZM8 0a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0V.75A.75.75 0 0 1 8 0ZM3 8a.75.75 0 0 1-.75.75H.75a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 3 8Zm13 0a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 16 8Zm-8 5a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 8 13Zm-3.536-9.193a.75.75 0 0 1-1.06 0L2.343 2.747a.75.75 0 1 1 1.06-1.06l1.06 1.06a.75.75 0 0 1 0 1.06Zm9.193 9.193a.75.75 0 0 1-1.06 0l-1.061-1.06a.75.75 0 0 1 1.06-1.06l1.06 1.06a.75.75 0 0 1 0 1.06Z"></path></svg>
+        <?php else: ?>
+          <svg class="icon" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M9.598 1.591a.749.749 0 0 1 .785-.175 7.001 7.001 0 1 1-8.967 8.967.75.75 0 0 1 .961-.96 5.5 5.5 0 0 0 7.046-7.046.75.75 0 0 1 .175-.786Zm1.616 1.945a7 7 0 0 1-7.678 7.678 5.499 5.499 0 1 0 7.678-7.678Z"></path></svg>
+        <?php endif; ?>
+      </a>
+    <?php endif; ?>
+  </div>
+</header>
+<?php if ($tab !== null && $repoName !== null) render('partials/tabs', ['repoName' => $repoName, 'cur' => $tab]); ?>
+<main><div class="inner">
diff --git a/views/partials/repo-subnav.php b/views/partials/repo-subnav.php
new file mode 100644
index 0000000..6d3db5e
--- /dev/null
+++ b/views/partials/repo-subnav.php
@@ -0,0 +1,31 @@
+<?php
+/** @var string $repoName @var string $curAction @var string $curRef
+ *  @var string[] $branches  empty array to hide the branch pills
+ *  The row above the content: branch pills, clone URL, feed links. */
+?>
+<div class="repo-subnav">
+  <?php if (count($branches) > 1): ?>
+    <span class="branches">
+      <svg class="icon" width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.492 2.492 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Z"></path></svg>
+      <?php foreach ($branches as $b): ?>
+        <a class="pill<?= $b === $curRef ? ' on' : '' ?>" href="?r=<?= h($repoName) ?>&a=<?= h($curAction) ?>&ref=<?= h($b) ?>"><?= h($b) ?></a>
+      <?php endforeach; ?>
+    </span>
+  <?php endif; ?>
+
+  <?php if (CLONE_BASE !== ''): ?>
+    <?php $cloneCmd = 'git clone ' . clone_url($repoName); ?>
+    <button type="button" class="clone-box" data-copy="<?= h($cloneCmd) ?>"
+            title="Kopiera klon-kommandot" aria-label="Kopiera klon-kommandot">
+      <span class="copy-icon" aria-hidden="true"><svg class="icon" width="16" height="16" viewBox="0 0 16 16"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"></path><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Z"></path></svg></span>
+      <span class="done-icon" aria-hidden="true"><svg class="icon" width="16" height="16" viewBox="0 0 16 16"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"></path></svg></span>
+      <code><?= h($cloneCmd) ?></code>
+    </button>
+  <?php endif; ?>
+
+  <span class="feeds">
+    <svg class="icon" width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="M2.965 12.535a1.5 1.5 0 1 1 2.122 2.122 1.5 1.5 0 0 1-2.122-2.122ZM2 9.5a.75.75 0 0 1 .75-.75A4.5 4.5 0 0 1 7.25 13.25a.75.75 0 0 1-1.5 0A3 3 0 0 0 2.75 10.25.75.75 0 0 1 2 9.5Zm0-4a.75.75 0 0 1 .75-.75A8.5 8.5 0 0 1 11.25 13.25a.75.75 0 0 1-1.5 0A7 7 0 0 0 2.75 6.25.75.75 0 0 1 2 5.5Z"></path></svg>
+    <a href="?r=<?= h($repoName) ?>&a=atom&ref=<?= h($curRef) ?>">commits</a>
+    <a href="?r=<?= h($repoName) ?>&a=atom-tags">tags</a>
+  </span>
+</div>
diff --git a/views/partials/tabs.php b/views/partials/tabs.php
new file mode 100644
index 0000000..de57e19
--- /dev/null
+++ b/views/partials/tabs.php
@@ -0,0 +1,15 @@
+<?php
+/** @var string $repoName @var string $cur */
+$tabs = [
+    'tree' => ['Files',   '<svg class="icon" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"></path></svg>'],
+    'log'  => ['Commits', '<svg class="icon" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M11.93 8.5a4.002 4.002 0 0 1-7.86 0H.75a.75.75 0 0 1 0-1.5h3.32a4.002 4.002 0 0 1 7.86 0h3.32a.75.75 0 0 1 0 1.5Zm-1.43-.75a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"></path></svg>'],
+    'refs' => ['Refs',    '<svg class="icon" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.492 2.492 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Z"></path></svg>'],
+];
+?>
+<nav class="tabs">
+  <div class="inner">
+    <?php foreach ($tabs as $a => [$label, $icon]): ?>
+      <a<?= $cur === $a ? ' class="on"' : '' ?> href="?r=<?= h($repoName) ?>&a=<?= $a ?>"><?= $icon ?><?= h($label) ?></a>
+    <?php endforeach; ?>
+  </div>
+</nav>
diff --git a/views/partials/theme-switcher.php b/views/partials/theme-switcher.php
new file mode 100644
index 0000000..ca82eca
--- /dev/null
+++ b/views/partials/theme-switcher.php
@@ -0,0 +1,20 @@
+<?php
+/** @var string $theme @var string[] $themes
+ *  Theme dropdown, shown when there's more than one theme to pick from.
+ *  Reads $_GET directly (rather than taking it as a param) since its whole
+ *  job is to reflect the current request back into a self-submitting form —
+ *  preserves r/a/ref/... so switching theme doesn't lose your place. */
+if (count($themes) <= 1) return;
+?>
+<form class="theme-switch" method="get">
+  <?php foreach ($_GET as $k => $v): ?>
+    <?php if ($k === 'theme' || !is_string($v)) continue; ?>
+    <input type="hidden" name="<?= h($k) ?>" value="<?= h($v) ?>">
+  <?php endforeach; ?>
+  <select name="theme" onchange="this.form.submit()">
+    <?php foreach ($themes as $t): ?>
+      <option value="<?= h($t) ?>"<?= $t === $theme ? ' selected' : '' ?>><?= h($t) ?></option>
+    <?php endforeach; ?>
+  </select>
+  <noscript><button type="submit">byt</button></noscript>
+</form>
diff --git a/views/refs.php b/views/refs.php
new file mode 100644
index 0000000..847871f
--- /dev/null
+++ b/views/refs.php
@@ -0,0 +1,32 @@
+<?php
+/** @var string $repoName @var array $refs */
+$branches = array_values(array_filter($refs, fn($r) => $r['kind'] === 'branch'));
+$tags     = array_values(array_filter($refs, fn($r) => $r['kind'] === 'tag'));
+$branchIcon = '<svg class="icon" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.492 2.492 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Z"></path></svg>';
+$tagIcon    = '<svg class="icon" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M1 7.775V2.75C1 1.784 1.784 1 2.75 1h5.025c.464 0 .91.184 1.238.513l6.25 6.25a1.75 1.75 0 0 1 0 2.474l-5.026 5.026a1.75 1.75 0 0 1-2.474 0l-6.25-6.25A1.752 1.752 0 0 1 1 7.775Zm1.5 0c0 .066.026.13.073.177l6.25 6.25a.25.25 0 0 0 .354 0l5.025-5.025a.25.25 0 0 0 0-.354l-6.25-6.25a.25.25 0 0 0-.177-.073H2.75a.25.25 0 0 0-.25.25ZM6 5a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z"></path></svg>';
+?>
+<div class="box">
+  <div class="box-header"><?= $branchIcon ?> Branches</div>
+  <?php if (!$branches): ?>
+    <p style="margin:12px 16px" class="desc">No branches.</p>
+  <?php else: ?>
+  <table><tbody>
+    <?php foreach ($branches as $r): ?>
+      <tr><td><a href="?r=<?= h($repoName) ?>&a=log&ref=<?= h($r['short']) ?>"><?= h($r['short']) ?></a></td></tr>
+    <?php endforeach; ?>
+  </tbody></table>
+  <?php endif; ?>
+</div>
+
+<div class="box">
+  <div class="box-header"><?= $tagIcon ?> Tags</div>
+  <?php if (!$tags): ?>
+    <p style="margin:12px 16px" class="desc">No tags.</p>
+  <?php else: ?>
+  <table><tbody>
+    <?php foreach ($tags as $r): ?>
+      <tr><td><?= h($r['short']) ?></td></tr>
+    <?php endforeach; ?>
+  </tbody></table>
+  <?php endif; ?>
+</div>
diff --git a/views/repo-index.php b/views/repo-index.php
new file mode 100644
index 0000000..5959e37
--- /dev/null
+++ b/views/repo-index.php
@@ -0,0 +1,17 @@
+<?php /** @var array $repos */ ?>
+<?php if (!$repos): ?>
+<p class="desc">No repositories found in <?= h(REPO_BASE) ?>.</p>
+<?php else: ?>
+<table id="repo-list"><tr><th>Repository</th><th>Description</th><th>Updated</th></tr>
+<?php foreach ($repos as $name => $meta): ?>
+  <tr>
+    <td>
+      <svg class="icon" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8ZM5 12.25a.25.25 0 0 1 .25-.25h3.5a.25.25 0 0 1 .25.25v3.25a.25.25 0 0 1-.4.2l-1.45-1.087a.249.249 0 0 0-.3 0L5.4 15.7a.25.25 0 0 1-.4-.2Z"></path></svg>
+      <a href="?r=<?= h($name) ?>"><?= h(repo_display_name($name)) ?></a>
+    </td>
+    <td><?= h($meta['desc']) ?></td>
+    <td><?= $meta['mtime'] !== 0 ? h(date('Y-m-d', $meta['mtime'])) : '' ?></td>
+  </tr>
+<?php endforeach; ?>
+</table>
+<?php endif; ?>
diff --git a/views/tree.php b/views/tree.php
new file mode 100644
index 0000000..1bea92a
--- /dev/null
+++ b/views/tree.php
@@ -0,0 +1,42 @@
+<?php
+/** @var string $repoName @var string $ref @var string $path @var array $entries
+ *  @var ?string $readmeName @var ?string $readmeHtml @var ?string $licenseName */
+$folderIcon = '<svg class="icon" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true" style="color:var(--acc)"><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z"></path></svg>';
+$fileIcon   = '<svg class="icon" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true" style="color:var(--dim)"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"></path></svg>';
+$readmeIcon = '<svg class="icon" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M0 1.75A.75.75 0 0 1 .75 1h4.253c1.227 0 2.317.59 3 1.501A3.743 3.743 0 0 1 11.006 1h4.245a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-4.507a2.25 2.25 0 0 0-1.591.659l-.622.621a.75.75 0 0 1-1.06 0l-.622-.621A2.25 2.25 0 0 0 5.258 13H.75a.75.75 0 0 1-.75-.75Zm7.251 10.324.004-5.073-.002-2.253A2.25 2.25 0 0 0 5.003 2.5H1.5v9h3.757a3.75 3.75 0 0 1 1.994.574ZM8.755 4.75l-.004 7.322a3.752 3.752 0 0 1 1.992-.572H14.5v-9h-3.495a2.25 2.25 0 0 0-2.25 2.25Z"></path></svg>';
+?>
+<?php if ($path !== ''): ?>
+<p class="desc">/<?= h($path) ?></p>
+<?php endif; ?>
+
+<div class="box">
+<table class="tree">
+<thead><tr><th>Mode</th><th>Name</th><th class="num">Size</th></tr></thead>
+<tbody>
+<?php foreach ($entries as $e): ?>
+  <tr>
+    <td class="mode" title="<?= h($e['mode']) ?>"><?= $e['isTree'] ? $folderIcon : $fileIcon ?></td>
+    <td>
+      <?php if ($e['isTree']): ?>
+        <a href="?r=<?= h($repoName) ?>&a=tree&ref=<?= h($ref) ?>&path=<?= h($e['child']) ?>"><?= h($e['name']) ?></a>
+      <?php else: ?>
+        <a href="?r=<?= h($repoName) ?>&a=tree&ref=<?= h($ref) ?>&blob=<?= h($e['child']) ?>"><?= h($e['name']) ?></a>
+      <?php endif; ?>
+    </td>
+    <td class="num hash"><?= h(format_size($e['size'])) ?></td>
+  </tr>
+<?php endforeach; ?>
+</tbody>
+</table>
+</div>
+
+<?php if ($licenseName !== null): ?>
+<p class="license-line">license: <a href="?r=<?= h($repoName) ?>&a=tree&ref=<?= h($ref) ?>&blob=<?= h($licenseName) ?>"><?= h($licenseName) ?></a></p>
+<?php endif; ?>
+
+<?php if ($readmeName !== null): ?>
+<div class="box readme">
+  <div class="box-header"><?= $readmeIcon ?> <?= h($readmeName) ?></div>
+  <div class="body"><?= $readmeHtml ?></div>
+</div>
+<?php endif; ?>