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; ?>