frieren

~ / frieren

a self-hosted git server in one binary — everyone reads, only the owner writes

feat: single-binary git server with smart HTTP and read-only web UI

81c694348fc919ff32a3f94a1317dba38b42365a

justin06lee · Aug 18, 2026 15:08 (6h ago)

 e2e_test.go             | 212 +++++++++++++++++++++++++++
 gitcmd.go               | 320 +++++++++++++++++++++++++++++++++++++++++
 githttp.go              | 189 ++++++++++++++++++++++++
 main.go                 | 101 +++++++++++++
 server.go               |  42 ++++++
 static/style.css        | 124 ++++++++++++++++
 templates/blob.html     |  14 ++
 templates/commit.html   |  11 ++
 templates/commits.html  |  18 +++
 templates/index.html    |  21 +++
 templates/notfound.html |   6 +
 templates/parts.html    |  35 +++++
 templates/refs.html     |  30 ++++
 templates/repo.html     |  32 +++++
 templates/tree.html     |  20 +++
 web.go                  | 372 ++++++++++++++++++++++++++++++++++++++++++++++++
 16 files changed, 1547 insertions(+)

diff --git a/e2e_test.go b/e2e_test.go
new file mode 100644
index 0000000..ab841c4
--- /dev/null
+++ b/e2e_test.go
@@ -0,0 +1,212 @@
+package main
+
+import (
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+const testToken = "e2e-test-token"
+
+func newTestServer(t *testing.T) (*httptest.Server, *Store) {
+	t.Helper()
+	store := &Store{Root: t.TempDir()}
+	srv := &Server{Store: store, Token: testToken}
+	ts := httptest.NewServer(srv.handler())
+	t.Cleanup(ts.Close)
+	return ts, store
+}
+
+// gitCmd runs git isolated from the developer's global config and credentials.
+func gitCmd(t *testing.T, dir string, args ...string) (string, error) {
+	t.Helper()
+	base := []string{"-c", "user.name=e2e", "-c", "user.email=e2e@test", "-c", "init.defaultBranch=master"}
+	cmd := exec.Command("git", append(base, args...)...)
+	cmd.Dir = dir
+	cmd.Env = append(os.Environ(),
+		"GIT_TERMINAL_PROMPT=0",
+		"GIT_CONFIG_GLOBAL=/dev/null",
+		"GIT_CONFIG_SYSTEM=/dev/null",
+		"GIT_ASKPASS=/usr/bin/false",
+	)
+	out, err := cmd.CombinedOutput()
+	return string(out), err
+}
+
+func mustGit(t *testing.T, dir string, args ...string) string {
+	t.Helper()
+	out, err := gitCmd(t, dir, args...)
+	if err != nil {
+		t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
+	}
+	return out
+}
+
+// withToken embeds the owner token as basic-auth credentials in a clone URL.
+func withToken(t *testing.T, raw string) string {
+	t.Helper()
+	u, err := url.Parse(raw)
+	if err != nil {
+		t.Fatal(err)
+	}
+	u.User = url.UserPassword("owner", testToken)
+	return u.String()
+}
+
+func get(t *testing.T, url string) (int, string) {
+	t.Helper()
+	resp, err := http.Get(url)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer resp.Body.Close()
+	body, _ := io.ReadAll(resp.Body)
+	return resp.StatusCode, string(body)
+}
+
+func TestCloneAndPushRoundtrip(t *testing.T) {
+	ts, store := newTestServer(t)
+	if err := store.create("demo", "a test repository"); err != nil {
+		t.Fatal(err)
+	}
+
+	work := t.TempDir()
+	mustGit(t, work, "clone", ts.URL+"/demo.git", "clone1")
+	clone1 := filepath.Join(work, "clone1")
+
+	if err := os.WriteFile(filepath.Join(clone1, "hello.txt"), []byte("hello from frieren\n"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	mustGit(t, clone1, "add", "hello.txt")
+	mustGit(t, clone1, "commit", "-m", "add hello")
+
+	// Push without the token must be rejected.
+	if out, err := gitCmd(t, clone1, "push", "origin", "master"); err == nil {
+		t.Fatalf("anonymous push succeeded, want auth failure:\n%s", out)
+	}
+
+	// Push with the token must land.
+	mustGit(t, clone1, "push", withToken(t, ts.URL+"/demo.git"), "master")
+
+	// A fresh anonymous clone sees the pushed content.
+	mustGit(t, work, "clone", ts.URL+"/demo.git", "clone2")
+	got, err := os.ReadFile(filepath.Join(work, "clone2", "hello.txt"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if string(got) != "hello from frieren\n" {
+		t.Fatalf("clone content = %q", got)
+	}
+}
+
+func TestPushToCreate(t *testing.T) {
+	ts, store := newTestServer(t)
+
+	work := t.TempDir()
+	mustGit(t, work, "init", "fresh")
+	dir := filepath.Join(work, "fresh")
+	if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	mustGit(t, dir, "add", ".")
+	mustGit(t, dir, "commit", "-m", "first")
+
+	// Creating a repo anonymously must fail.
+	if out, err := gitCmd(t, dir, "push", ts.URL+"/newrepo.git", "master"); err == nil {
+		t.Fatalf("anonymous push-to-create succeeded:\n%s", out)
+	}
+	if store.exists("newrepo") {
+		t.Fatal("repository created by unauthenticated push")
+	}
+
+	// With the token the repository springs into existence.
+	mustGit(t, dir, "push", withToken(t, ts.URL+"/newrepo.git"), "master")
+	if !store.exists("newrepo") {
+		t.Fatal("push-to-create did not create the repository")
+	}
+
+	// Invalid names never become directories.
+	if out, err := gitCmd(t, dir, "push", withToken(t, ts.URL+"/.hidden.git"), "master"); err == nil {
+		t.Fatalf("push to invalid repo name succeeded:\n%s", out)
+	}
+}
+
+func TestWebPages(t *testing.T) {
+	ts, store := newTestServer(t)
+	if err := store.create("site", "web smoke test"); err != nil {
+		t.Fatal(err)
+	}
+
+	work := t.TempDir()
+	mustGit(t, work, "clone", ts.URL+"/site.git", "w")
+	dir := filepath.Join(work, "w")
+	os.MkdirAll(filepath.Join(dir, "docs"), 0o755)
+	os.WriteFile(filepath.Join(dir, "README.md"), []byte("# site\nreadme body here\n"), 0o644)
+	os.WriteFile(filepath.Join(dir, "docs", "guide.txt"), []byte("guide line one\n"), 0o644)
+	mustGit(t, dir, "add", ".")
+	mustGit(t, dir, "commit", "-m", "add docs")
+	mustGit(t, dir, "push", withToken(t, ts.URL+"/site.git"), "master")
+
+	for _, tc := range []struct{ path, want string }{
+		{"/", "site"},
+		{"/site", "readme body here"},
+		{"/site/tree/master/docs", "guide.txt"},
+		{"/site/blob/master/docs/guide.txt", "guide line one"},
+		{"/site/raw/master/docs/guide.txt", "guide line one"},
+		{"/site/commits", "add docs"},
+		{"/site/refs", "master"},
+	} {
+		code, body := get(t, ts.URL+tc.path)
+		if code != http.StatusOK {
+			t.Errorf("GET %s = %d", tc.path, code)
+			continue
+		}
+		if !strings.Contains(body, tc.want) {
+			t.Errorf("GET %s: missing %q", tc.path, tc.want)
+		}
+	}
+
+	// Commit page renders the diff.
+	commits, err := store.log(t.Context(), "site", "master", 10)
+	if err != nil || len(commits) == 0 {
+		t.Fatalf("log: %v", err)
+	}
+	code, body := get(t, ts.URL+"/site/commit/"+commits[0].Hash)
+	if code != http.StatusOK || !strings.Contains(body, "guide line one") {
+		t.Errorf("commit page: code %d, diff shown: %v", code, strings.Contains(body, "guide line one"))
+	}
+
+	// Traversal and junk stay 404.
+	for _, path := range []string{"/nope", "/site/blob/master/../../etc/passwd", "/site/raw/master/%2e%2e/x"} {
+		if code, _ := get(t, ts.URL+path); code != http.StatusNotFound {
+			t.Errorf("GET %s = %d, want 404", path, code)
+		}
+	}
+}
+
+func TestReadOnlyWithoutToken(t *testing.T) {
+	store := &Store{Root: t.TempDir()}
+	srv := &Server{Store: store, Token: ""}
+	ts := httptest.NewServer(srv.handler())
+	defer ts.Close()
+
+	work := t.TempDir()
+	mustGit(t, work, "init", "r")
+	dir := filepath.Join(work, "r")
+	os.WriteFile(filepath.Join(dir, "x"), []byte("x"), 0o644)
+	mustGit(t, dir, "add", ".")
+	mustGit(t, dir, "commit", "-m", "x")
+
+	// Even a well-formed credential is refused when no token is configured.
+	u, _ := url.Parse(ts.URL + "/r.git")
+	u.User = url.UserPassword("owner", "anything")
+	if out, err := gitCmd(t, dir, "push", u.String(), "master"); err == nil {
+		t.Fatalf("push succeeded on tokenless server:\n%s", out)
+	}
+}
diff --git a/gitcmd.go b/gitcmd.go
new file mode 100644
index 0000000..faaab78
--- /dev/null
+++ b/gitcmd.go
@@ -0,0 +1,320 @@
+package main
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"regexp"
+	"sort"
+	"strconv"
+	"strings"
+	"time"
+)
+
+// All repository access goes through the real git binary: frieren owns HTTP,
+// auth, and rendering, while git owns object storage and the pack protocol.
+
+var repoNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
+
+// reservedNames are path roots the web UI claims for itself.
+var reservedNames = map[string]bool{"static": true}
+
+func validRepoName(name string) bool {
+	return repoNameRe.MatchString(name) && !reservedNames[name] && len(name) <= 100
+}
+
+func validRef(ref string) bool {
+	if ref == "" || len(ref) > 250 || strings.HasPrefix(ref, "-") || strings.Contains(ref, "..") {
+		return false
+	}
+	for _, r := range ref {
+		if r < 0x20 || r == 0x7f || r == ' ' || r == '~' || r == '^' || r == ':' || r == '\\' {
+			return false
+		}
+	}
+	return true
+}
+
+func validPath(p string) bool {
+	if p == "" {
+		return true
+	}
+	if strings.HasPrefix(p, "/") || strings.Contains(p, "\x00") {
+		return false
+	}
+	for _, seg := range strings.Split(p, "/") {
+		if seg == "" || seg == "." || seg == ".." {
+			return false
+		}
+	}
+	return true
+}
+
+type Store struct {
+	Root string
+}
+
+func (s *Store) repoPath(name string) string {
+	return filepath.Join(s.Root, name+".git")
+}
+
+func (s *Store) exists(name string) bool {
+	if !validRepoName(name) {
+		return false
+	}
+	fi, err := os.Stat(filepath.Join(s.repoPath(name), "HEAD"))
+	return err == nil && fi.Mode().IsRegular()
+}
+
+func (s *Store) create(name, description string) error {
+	if !validRepoName(name) {
+		return fmt.Errorf("invalid repository name %q", name)
+	}
+	if s.exists(name) {
+		return fmt.Errorf("repository %q already exists", name)
+	}
+	if _, err := runGit(context.Background(), s.Root, nil, "init", "--bare", "-b", "master", "--quiet", name+".git"); err != nil {
+		return err
+	}
+	if description != "" {
+		return os.WriteFile(filepath.Join(s.repoPath(name), "description"), []byte(description+"\n"), 0o644)
+	}
+	return nil
+}
+
+// runGit executes git with -C dir and returns stdout. Stderr is folded into
+// the error so callers can surface git's own explanation.
+func runGit(ctx context.Context, dir string, stdin []byte, args ...string) ([]byte, error) {
+	full := append([]string{"-C", dir}, args...)
+	cmd := exec.CommandContext(ctx, "git", full...)
+	if stdin != nil {
+		cmd.Stdin = bytes.NewReader(stdin)
+	}
+	var out, errb bytes.Buffer
+	cmd.Stdout = &out
+	cmd.Stderr = &errb
+	if err := cmd.Run(); err != nil {
+		return nil, fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(errb.String()))
+	}
+	return out.Bytes(), nil
+}
+
+type RepoInfo struct {
+	Name        string
+	Description string
+	Default     string
+	LastCommit  time.Time
+	Empty       bool
+}
+
+func (s *Store) open(name string) (*RepoInfo, error) {
+	if !s.exists(name) {
+		return nil, fmt.Errorf("no such repository %q", name)
+	}
+	info := &RepoInfo{Name: name, Default: "master"}
+	dir := s.repoPath(name)
+	if b, err := os.ReadFile(filepath.Join(dir, "description")); err == nil {
+		d := strings.TrimSpace(string(b))
+		if !strings.HasPrefix(d, "Unnamed repository") {
+			info.Description = d
+		}
+	}
+	if b, err := runGit(context.Background(), dir, nil, "symbolic-ref", "--short", "HEAD"); err == nil {
+		info.Default = strings.TrimSpace(string(b))
+	}
+	b, err := runGit(context.Background(), dir, nil,
+		"for-each-ref", "--sort=-committerdate", "--count=1", "--format=%(committerdate:unix)", "refs/heads")
+	if err != nil || len(bytes.TrimSpace(b)) == 0 {
+		info.Empty = true
+		return info, nil
+	}
+	if unix, err := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64); err == nil {
+		info.LastCommit = time.Unix(unix, 0)
+	}
+	return info, nil
+}
+
+func (s *Store) list() []*RepoInfo {
+	entries, err := os.ReadDir(s.Root)
+	if err != nil {
+		return nil
+	}
+	var repos []*RepoInfo
+	for _, e := range entries {
+		if !e.IsDir() || !strings.HasSuffix(e.Name(), ".git") {
+			continue
+		}
+		name := strings.TrimSuffix(e.Name(), ".git")
+		if info, err := s.open(name); err == nil {
+			repos = append(repos, info)
+		}
+	}
+	sort.Slice(repos, func(i, j int) bool { return repos[i].LastCommit.After(repos[j].LastCommit) })
+	return repos
+}
+
+type TreeEntry struct {
+	Mode string
+	Type string // blob, tree, commit (submodule)
+	Hash string
+	Size int64 // -1 for trees
+	Name string
+}
+
+func (s *Store) lsTree(ctx context.Context, repo, ref, path string) ([]TreeEntry, error) {
+	spec := ref
+	if path != "" {
+		spec += ":" + path
+	}
+	out, err := runGit(ctx, s.repoPath(repo), nil, "ls-tree", "-z", "-l", spec)
+	if err != nil {
+		return nil, err
+	}
+	var entries []TreeEntry
+	for _, rec := range bytes.Split(out, []byte{0}) {
+		if len(rec) == 0 {
+			continue
+		}
+		meta, name, ok := bytes.Cut(rec, []byte{'\t'})
+		if !ok {
+			continue
+		}
+		f := strings.Fields(string(meta))
+		if len(f) != 4 {
+			continue
+		}
+		size := int64(-1)
+		if f[3] != "-" {
+			size, _ = strconv.ParseInt(f[3], 10, 64)
+		}
+		entries = append(entries, TreeEntry{Mode: f[0], Type: f[1], Hash: f[2], Size: size, Name: string(name)})
+	}
+	sort.Slice(entries, func(i, j int) bool {
+		if (entries[i].Type == "tree") != (entries[j].Type == "tree") {
+			return entries[i].Type == "tree"
+		}
+		return entries[i].Name < entries[j].Name
+	})
+	return entries, nil
+}
+
+const maxBlobBytes = 2 << 20 // 2 MiB shown in the web UI; raw endpoint streams everything
+
+func (s *Store) catBlob(ctx context.Context, repo, ref, path string) ([]byte, error) {
+	return runGit(ctx, s.repoPath(repo), nil, "cat-file", "blob", ref+":"+path)
+}
+
+type Commit struct {
+	Hash    string
+	Short   string
+	Author  string
+	When    time.Time
+	Subject string
+}
+
+const logFormat = "%H%x1f%h%x1f%an%x1f%at%x1f%s%x1e"
+
+func parseCommits(out []byte) []Commit {
+	var commits []Commit
+	for _, rec := range bytes.Split(out, []byte{0x1e}) {
+		rec = bytes.TrimSpace(rec)
+		if len(rec) == 0 {
+			continue
+		}
+		f := strings.Split(string(rec), "\x1f")
+		if len(f) != 5 {
+			continue
+		}
+		unix, _ := strconv.ParseInt(f[3], 10, 64)
+		commits = append(commits, Commit{Hash: f[0], Short: f[1], Author: f[2], When: time.Unix(unix, 0), Subject: f[4]})
+	}
+	return commits
+}
+
+func (s *Store) log(ctx context.Context, repo, ref string, limit int) ([]Commit, error) {
+	out, err := runGit(ctx, s.repoPath(repo), nil,
+		"log", "--format="+logFormat, "-n", strconv.Itoa(limit), ref, "--")
+	if err != nil {
+		return nil, err
+	}
+	return parseCommits(out), nil
+}
+
+func (s *Store) commit(ctx context.Context, repo, hash string) (*Commit, error) {
+	out, err := runGit(ctx, s.repoPath(repo), nil, "show", "-s", "--format="+logFormat, hash, "--")
+	if err != nil {
+		return nil, err
+	}
+	commits := parseCommits(out)
+	if len(commits) == 0 {
+		return nil, fmt.Errorf("no such commit %q", hash)
+	}
+	return &commits[0], nil
+}
+
+const maxDiffBytes = 1 << 20 // 1 MiB of rendered patch per commit page
+
+func (s *Store) patch(ctx context.Context, repo, hash string) (string, bool, error) {
+	out, err := runGit(ctx, s.repoPath(repo), nil,
+		"show", "--format=", "--stat", "--patch", "--no-color", hash, "--")
+	if err != nil {
+		return "", false, err
+	}
+	if len(out) > maxDiffBytes {
+		return string(out[:maxDiffBytes]), true, nil
+	}
+	return string(out), false, nil
+}
+
+type Ref struct {
+	Name    string
+	Short   string
+	When    time.Time
+	Subject string
+}
+
+func (s *Store) refs(ctx context.Context, repo, kind string) ([]Ref, error) {
+	out, err := runGit(ctx, s.repoPath(repo), nil,
+		"for-each-ref", "--sort=-creatordate",
+		"--format=%(refname:short)%1f%(objectname:short)%1f%(creatordate:unix)%1f%(subject)%1e", "refs/"+kind)
+	if err != nil {
+		return nil, err
+	}
+	var refs []Ref
+	for _, rec := range bytes.Split(out, []byte{0x1e}) {
+		rec = bytes.TrimSpace(rec)
+		if len(rec) == 0 {
+			continue
+		}
+		f := strings.Split(string(rec), "\x1f")
+		if len(f) != 4 {
+			continue
+		}
+		unix, _ := strconv.ParseInt(f[2], 10, 64)
+		refs = append(refs, Ref{Name: f[0], Short: f[1], When: time.Unix(unix, 0), Subject: f[3]})
+	}
+	return refs, nil
+}
+
+// readme returns the first README-ish blob at the root of ref, if any.
+func (s *Store) readme(ctx context.Context, repo, ref string) (name string, body []byte) {
+	entries, err := s.lsTree(ctx, repo, ref, "")
+	if err != nil {
+		return "", nil
+	}
+	for _, e := range entries {
+		if e.Type != "blob" {
+			continue
+		}
+		switch strings.ToLower(e.Name) {
+		case "readme.md", "readme", "readme.txt":
+			if b, err := s.catBlob(ctx, repo, ref, e.Name); err == nil && len(b) <= maxBlobBytes {
+				return e.Name, b
+			}
+		}
+	}
+	return "", nil
+}
diff --git a/githttp.go b/githttp.go
new file mode 100644
index 0000000..5fcb2e3
--- /dev/null
+++ b/githttp.go
@@ -0,0 +1,189 @@
+package main
+
+import (
+	"compress/gzip"
+	"crypto/sha256"
+	"crypto/subtle"
+	"fmt"
+	"io"
+	"log"
+	"net/http"
+	"os"
+	"os/exec"
+)
+
+// Smart HTTP endpoints. frieren speaks just enough of the transport to route
+// and authenticate, then hands the byte stream to git's own plumbing:
+//
+//	GET  /{repo}(.git)/info/refs?service=git-upload-pack   → ref advertisement (anonymous)
+//	POST /{repo}(.git)/git-upload-pack                     → clone/fetch (anonymous)
+//	GET  /{repo}(.git)/info/refs?service=git-receive-pack  → push advertisement (owner only)
+//	POST /{repo}(.git)/git-receive-pack                    → push (owner only)
+//
+// A push to a repository that doesn't exist yet creates it (owner only).
+
+func pktLine(s string) string {
+	return fmt.Sprintf("%04x%s", len(s)+4, s)
+}
+
+// authorized reports whether the request carries the owner token as the HTTP
+// Basic password. The username is ignored. With no token configured the
+// server is read-only for everyone, owner included.
+func (srv *Server) authorized(r *http.Request) bool {
+	if srv.Token == "" {
+		return false
+	}
+	_, pass, ok := r.BasicAuth()
+	if !ok {
+		return false
+	}
+	want := sha256.Sum256([]byte(srv.Token))
+	got := sha256.Sum256([]byte(pass))
+	return subtle.ConstantTimeCompare(want[:], got[:]) == 1
+}
+
+func requireAuth(w http.ResponseWriter) {
+	w.Header().Set("WWW-Authenticate", `Basic realm="frieren"`)
+	http.Error(w, "authentication required", http.StatusUnauthorized)
+}
+
+// ensureRepo resolves the repo for a push, creating it on first push.
+func (srv *Server) ensureRepo(w http.ResponseWriter, name string) bool {
+	if srv.Store.exists(name) {
+		return true
+	}
+	if !validRepoName(name) {
+		http.Error(w, "invalid repository name", http.StatusBadRequest)
+		return false
+	}
+	if err := srv.Store.create(name, ""); err != nil {
+		log.Printf("create %s: %v", name, err)
+		http.Error(w, "could not create repository", http.StatusInternalServerError)
+		return false
+	}
+	log.Printf("created repository %s.git", name)
+	return true
+}
+
+func (srv *Server) infoRefs(w http.ResponseWriter, r *http.Request) {
+	name := repoParam(r)
+	service := r.URL.Query().Get("service")
+	switch service {
+	case "git-upload-pack":
+		if !srv.Store.exists(name) {
+			http.NotFound(w, r)
+			return
+		}
+	case "git-receive-pack":
+		if !srv.authorized(r) {
+			requireAuth(w)
+			return
+		}
+		if !srv.ensureRepo(w, name) {
+			return
+		}
+	default:
+		http.Error(w, "smart HTTP only", http.StatusForbidden)
+		return
+	}
+
+	sub := service[len("git-"):]
+	cmd := exec.CommandContext(r.Context(), "git", sub, "--stateless-rpc", "--advertise-refs", srv.Store.repoPath(name))
+	cmd.Env = gitEnv(r)
+	out, err := cmd.Output()
+	if err != nil {
+		log.Printf("%s advertise %s: %v", sub, name, err)
+		http.Error(w, "git error", http.StatusInternalServerError)
+		return
+	}
+	w.Header().Set("Content-Type", "application/x-"+service+"-advertisement")
+	w.Header().Set("Cache-Control", "no-cache")
+	io.WriteString(w, pktLine("# service="+service+"\n"))
+	io.WriteString(w, "0000")
+	w.Write(out)
+}
+
+func (srv *Server) uploadPack(w http.ResponseWriter, r *http.Request) {
+	name := repoParam(r)
+	if !srv.Store.exists(name) {
+		http.NotFound(w, r)
+		return
+	}
+	srv.serviceRPC(w, r, name, "upload-pack")
+}
+
+func (srv *Server) receivePack(w http.ResponseWriter, r *http.Request) {
+	name := repoParam(r)
+	if !srv.authorized(r) {
+		requireAuth(w)
+		return
+	}
+	if !srv.ensureRepo(w, name) {
+		return
+	}
+	srv.serviceRPC(w, r, name, "receive-pack")
+}
+
+func (srv *Server) serviceRPC(w http.ResponseWriter, r *http.Request, name, sub string) {
+	body := io.Reader(r.Body)
+	if r.Header.Get("Content-Encoding") == "gzip" {
+		gz, err := gzip.NewReader(body)
+		if err != nil {
+			http.Error(w, "bad gzip body", http.StatusBadRequest)
+			return
+		}
+		defer gz.Close()
+		body = gz
+	}
+
+	cmd := exec.CommandContext(r.Context(), "git", sub, "--stateless-rpc", srv.Store.repoPath(name))
+	cmd.Env = gitEnv(r)
+	cmd.Stdin = body
+	cmd.Stderr = os.Stderr
+
+	stdout, err := cmd.StdoutPipe()
+	if err != nil {
+		http.Error(w, "git error", http.StatusInternalServerError)
+		return
+	}
+	if err := cmd.Start(); err != nil {
+		log.Printf("%s %s: %v", sub, name, err)
+		http.Error(w, "git error", http.StatusInternalServerError)
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/x-git-"+sub+"-result")
+	w.Header().Set("Cache-Control", "no-cache")
+	io.Copy(newFlushWriter(w), stdout)
+	if err := cmd.Wait(); err != nil {
+		// Headers are already sent; all we can do is log.
+		log.Printf("%s %s: %v", sub, name, err)
+	}
+}
+
+// gitEnv forwards the client's Git-Protocol header so protocol v2 works.
+func gitEnv(r *http.Request) []string {
+	env := os.Environ()
+	if p := r.Header.Get("Git-Protocol"); p != "" {
+		env = append(env, "GIT_PROTOCOL="+p)
+	}
+	return env
+}
+
+type flushWriter struct {
+	w io.Writer
+	f http.Flusher
+}
+
+func newFlushWriter(w http.ResponseWriter) io.Writer {
+	if f, ok := w.(http.Flusher); ok {
+		return &flushWriter{w: w, f: f}
+	}
+	return w
+}
+
+func (fw *flushWriter) Write(p []byte) (int, error) {
+	n, err := fw.w.Write(p)
+	fw.f.Flush()
+	return n, err
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..aa74941
--- /dev/null
+++ b/main.go
@@ -0,0 +1,101 @@
+package main
+
+import (
+	"crypto/rand"
+	"encoding/hex"
+	"flag"
+	"fmt"
+	"log"
+	"net/http"
+	"os"
+	"path/filepath"
+)
+
+var version = "dev"
+
+const usage = `frieren — a single-binary, self-hosted git server.
+Anyone can browse and clone; only the holder of the token can push.
+
+Usage:
+  frieren serve [-addr :7420] [-root DIR] [-token TOKEN]
+  frieren init <name> [description]     create an empty repository under the root
+  frieren token                         generate a random owner token
+  frieren version
+
+Environment:
+  FRIEREN_ADDR    listen address        (default :7420)
+  FRIEREN_ROOT    repository directory  (default ./repos)
+  FRIEREN_TOKEN   owner token; unset = server is read-only for everyone
+`
+
+func envOr(key, fallback string) string {
+	if v := os.Getenv(key); v != "" {
+		return v
+	}
+	return fallback
+}
+
+func main() {
+	log.SetFlags(log.LstdFlags)
+	if len(os.Args) < 2 {
+		fmt.Fprint(os.Stderr, usage)
+		os.Exit(2)
+	}
+	switch os.Args[1] {
+	case "serve":
+		serve(os.Args[2:])
+	case "init":
+		initRepo(os.Args[2:])
+	case "token":
+		buf := make([]byte, 32)
+		rand.Read(buf)
+		fmt.Println(hex.EncodeToString(buf))
+	case "version":
+		fmt.Println("frieren", version)
+	default:
+		fmt.Fprint(os.Stderr, usage)
+		os.Exit(2)
+	}
+}
+
+func serve(args []string) {
+	fs := flag.NewFlagSet("serve", flag.ExitOnError)
+	addr := fs.String("addr", envOr("FRIEREN_ADDR", ":7420"), "listen address")
+	root := fs.String("root", envOr("FRIEREN_ROOT", "./repos"), "repository directory")
+	token := fs.String("token", os.Getenv("FRIEREN_TOKEN"), "owner token (push access)")
+	fs.Parse(args)
+
+	absRoot, err := filepath.Abs(*root)
+	if err != nil {
+		log.Fatal(err)
+	}
+	if err := os.MkdirAll(absRoot, 0o755); err != nil {
+		log.Fatal(err)
+	}
+
+	srv := &Server{Store: &Store{Root: absRoot}, Token: *token}
+	if srv.Token == "" {
+		log.Print("WARNING: no token configured (FRIEREN_TOKEN) — pushing is disabled, serving read-only")
+	}
+	log.Printf("frieren %s serving %s on %s", version, absRoot, *addr)
+	log.Fatal(http.ListenAndServe(*addr, srv.handler()))
+}
+
+func initRepo(args []string) {
+	fs := flag.NewFlagSet("init", flag.ExitOnError)
+	root := fs.String("root", envOr("FRIEREN_ROOT", "./repos"), "repository directory")
+	fs.Parse(args)
+	if fs.NArg() < 1 {
+		fmt.Fprint(os.Stderr, usage)
+		os.Exit(2)
+	}
+	name, desc := fs.Arg(0), fs.Arg(1)
+	if err := os.MkdirAll(*root, 0o755); err != nil {
+		log.Fatal(err)
+	}
+	store := &Store{Root: *root}
+	if err := store.create(name, desc); err != nil {
+		log.Fatal(err)
+	}
+	fmt.Printf("created %s\n", store.repoPath(name))
+}
diff --git a/server.go b/server.go
new file mode 100644
index 0000000..fc2f5bf
--- /dev/null
+++ b/server.go
@@ -0,0 +1,42 @@
+package main
+
+import (
+	"net/http"
+	"strings"
+)
+
+type Server struct {
+	Store *Store
+	Token string
+}
+
+// repoParam extracts the repository name from the route, accepting both
+// /name and /name.git so one URL works in the browser and in git.
+func repoParam(r *http.Request) string {
+	return strings.TrimSuffix(r.PathValue("repo"), ".git")
+}
+
+func (srv *Server) handler() http.Handler {
+	mux := http.NewServeMux()
+
+	// Smart HTTP (git clients)
+	mux.HandleFunc("GET /{repo}/info/refs", srv.infoRefs)
+	mux.HandleFunc("POST /{repo}/git-upload-pack", srv.uploadPack)
+	mux.HandleFunc("POST /{repo}/git-receive-pack", srv.receivePack)
+
+	// Web UI (browsers, read-only)
+	mux.HandleFunc("GET /{$}", srv.indexPage)
+	mux.HandleFunc("GET /static/style.css", srv.styleCSS)
+	mux.HandleFunc("GET /{repo}", srv.repoPage)
+	mux.HandleFunc("GET /{repo}/{$}", srv.repoPage)
+	mux.HandleFunc("GET /{repo}/tree/{ref}", srv.treePage)
+	mux.HandleFunc("GET /{repo}/tree/{ref}/{path...}", srv.treePage)
+	mux.HandleFunc("GET /{repo}/blob/{ref}/{path...}", srv.blobPage)
+	mux.HandleFunc("GET /{repo}/raw/{ref}/{path...}", srv.rawFile)
+	mux.HandleFunc("GET /{repo}/commits", srv.commitsPage)
+	mux.HandleFunc("GET /{repo}/commits/{ref}", srv.commitsPage)
+	mux.HandleFunc("GET /{repo}/commit/{hash}", srv.commitPage)
+	mux.HandleFunc("GET /{repo}/refs", srv.refsPage)
+
+	return mux
+}
diff --git a/static/style.css b/static/style.css
new file mode 100644
index 0000000..d1f0f9e
--- /dev/null
+++ b/static/style.css
@@ -0,0 +1,124 @@
+:root {
+  --bg: #fdfdfc;
+  --fg: #1c1e21;
+  --muted: #6b7280;
+  --line: #e5e7eb;
+  --accent: #0f766e;
+  --card: #f4f5f4;
+  --add: #dcfce7;
+  --add-fg: #14532d;
+  --del: #fee2e2;
+  --del-fg: #7f1d1d;
+  --hunk: #eff6ff;
+  --hunk-fg: #1e40af;
+}
+@media (prefers-color-scheme: dark) {
+  :root {
+    --bg: #0c0c0f;
+    --fg: #d7dae0;
+    --muted: #8b93a1;
+    --line: #26282e;
+    --accent: #5eead4;
+    --card: #131418;
+    --add: #0d2818;
+    --add-fg: #86efac;
+    --del: #2d1214;
+    --del-fg: #fca5a5;
+    --hunk: #11203a;
+    --hunk-fg: #93c5fd;
+  }
+}
+
+* { box-sizing: border-box; }
+body {
+  margin: 0;
+  background: var(--bg);
+  color: var(--fg);
+  font: 15px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
+}
+main { max-width: 960px; margin: 0 auto; padding: 0 1rem 3rem; }
+a { color: var(--accent); text-decoration: none; }
+a:hover { text-decoration: underline; }
+code, pre, .hash, .mode, .clone { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
+
+header.site {
+  border-bottom: 1px solid var(--line);
+  padding: 0.7rem 1rem;
+  margin-bottom: 1.5rem;
+}
+.wordmark {
+  font-weight: 700;
+  letter-spacing: 0.06em;
+  color: var(--fg);
+}
+.wordmark::before { content: "❄ "; color: var(--accent); }
+footer.site {
+  border-top: 1px solid var(--line);
+  color: var(--muted);
+  font-size: 0.8rem;
+  padding: 1rem;
+  margin-top: 2rem;
+  text-align: center;
+}
+
+h1 { font-size: 1.25rem; margin: 0 0 0.25rem; }
+h1 a { color: var(--fg); }
+h1.home { margin-bottom: 1rem; }
+h2 { font-size: 1rem; margin: 1.5rem 0 0.5rem; }
+.desc { color: var(--muted); margin: 0 0 0.5rem; }
+
+.tabs { display: flex; align-items: center; gap: 1rem; border-bottom: 1px solid var(--line); padding: 0.4rem 0; margin-bottom: 1rem; flex-wrap: wrap; }
+.tabs a { color: var(--muted); }
+.tabs a.on { color: var(--fg); font-weight: 600; }
+.tabs .ref { font-size: 0.8rem; color: var(--accent); border: 1px solid var(--line); border-radius: 3px; padding: 0 0.4rem; }
+.clone { margin-left: auto; font-size: 0.8rem; color: var(--muted); background: var(--card); padding: 0.15rem 0.5rem; border-radius: 3px; user-select: all; }
+
+table { border-collapse: collapse; width: 100%; }
+td { padding: 0.35rem 0.75rem 0.35rem 0; border-bottom: 1px solid var(--line); vertical-align: baseline; }
+tr:last-child td { border-bottom: 0; }
+td.mode { color: var(--muted); font-size: 0.75rem; width: 6.5ch; }
+td.size, td.when { color: var(--muted); font-size: 0.85rem; text-align: right; white-space: nowrap; width: 1%; }
+td.hash { width: 9ch; }
+td.author { color: var(--muted); white-space: nowrap; }
+td.name { white-space: nowrap; }
+.repos td.desc, td.subject { color: var(--muted); max-width: 0; width: 99%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.repos td.name { font-weight: 600; }
+
+.crumbs { color: var(--muted); }
+.crumbs .filemeta { float: right; font-size: 0.85rem; }
+
+pre {
+  background: var(--card);
+  border: 1px solid var(--line);
+  border-radius: 4px;
+  padding: 0.75rem 1rem;
+  overflow-x: auto;
+  font-size: 0.85rem;
+  line-height: 1.5;
+}
+pre.code { counter-reset: l; padding-left: 0; }
+pre.code .cl::before {
+  counter-increment: l;
+  content: counter(l);
+  display: inline-block;
+  width: 3.5ch;
+  margin-right: 1.5ch;
+  padding-right: 0.5ch;
+  text-align: right;
+  color: var(--muted);
+  user-select: none;
+}
+
+pre.diff { padding-left: 1rem; }
+.d-meta { color: var(--muted); }
+.d-file { font-weight: 600; }
+.d-hunk { display: inline-block; width: 100%; background: var(--hunk); color: var(--hunk-fg); }
+.d-add { display: inline-block; width: 100%; background: var(--add); color: var(--add-fg); }
+.d-del { display: inline-block; width: 100%; background: var(--del); color: var(--del-fg); }
+
+.commitmeta p { margin: 0.15rem 0; color: var(--muted); }
+.commitmeta code { font-size: 0.8rem; }
+
+.empty { color: var(--muted); margin: 2rem 0; }
+.readme { margin-top: 2rem; }
+.readme pre { white-space: pre-wrap; }
diff --git a/templates/blob.html b/templates/blob.html
new file mode 100644
index 0000000..9f19ce5
--- /dev/null
+++ b/templates/blob.html
@@ -0,0 +1,14 @@
+{{define "blob"}}{{template "pagehead" .}}
+{{template "repohead" .}}
+{{$p := .}}
+<p class="crumbs"><a href="/{{.Repo.Name}}">{{.Repo.Name}}</a>{{range .Parents}} / <a href="/{{$p.Repo.Name}}/tree/{{pesc $p.Ref}}/{{.Path}}">{{.Name}}</a>{{end}} / <strong>{{.FileName}}</strong>
+  <span class="filemeta">{{size .Bytes}} · <a href="/{{.Repo.Name}}/raw/{{pesc .Ref}}/{{.RawPath}}">raw</a></span></p>
+{{if .Binary}}
+<div class="empty"><p>Binary file ({{size .Bytes}}). <a href="/{{.Repo.Name}}/raw/{{pesc .Ref}}/{{.RawPath}}">Download raw.</a></p></div>
+{{else if .Truncated}}
+<div class="empty"><p>File is too large to display ({{size .Bytes}}). <a href="/{{.Repo.Name}}/raw/{{pesc .Ref}}/{{.RawPath}}">View raw.</a></p></div>
+{{else}}
+<pre class="code"><code>{{range .Lines}}<span class="cl">{{.}}
+</span>{{end}}</code></pre>
+{{end}}
+{{template "pagefoot" .}}{{end}}
diff --git a/templates/commit.html b/templates/commit.html
new file mode 100644
index 0000000..acc487d
--- /dev/null
+++ b/templates/commit.html
@@ -0,0 +1,11 @@
+{{define "commit"}}{{template "pagehead" .}}
+{{template "repohead" .}}
+<div class="commitmeta">
+  <h2>{{.Commit.Subject}}</h2>
+  <p><code>{{.Commit.Hash}}</code></p>
+  <p>{{.Commit.Author}} · {{.Commit.When.Format "Jan 2, 2006 15:04"}} ({{ago .Commit.When}})</p>
+</div>
+<pre class="diff"><code>{{range .Diff}}<span class="d-{{.Class}}">{{.Text}}
+</span>{{end}}</code></pre>
+{{if .Truncated}}<p class="empty">Diff truncated — view the full change locally with <code>git show {{.Commit.Short}}</code>.</p>{{end}}
+{{template "pagefoot" .}}{{end}}
diff --git a/templates/commits.html b/templates/commits.html
new file mode 100644
index 0000000..1c171fa
--- /dev/null
+++ b/templates/commits.html
@@ -0,0 +1,18 @@
+{{define "commits"}}{{template "pagehead" .}}
+{{template "repohead" .}}
+{{$p := .}}
+{{if .Commits}}
+<table class="commits">
+  {{range .Commits}}
+  <tr>
+    <td class="hash"><a href="/{{$p.Repo.Name}}/commit/{{.Hash}}">{{.Short}}</a></td>
+    <td class="subject"><a href="/{{$p.Repo.Name}}/commit/{{.Hash}}">{{.Subject}}</a></td>
+    <td class="author">{{.Author}}</td>
+    <td class="when">{{ago .When}}</td>
+  </tr>
+  {{end}}
+</table>
+{{else}}
+<div class="empty"><p>No commits yet.</p></div>
+{{end}}
+{{template "pagefoot" .}}{{end}}
diff --git a/templates/index.html b/templates/index.html
new file mode 100644
index 0000000..05c1ff7
--- /dev/null
+++ b/templates/index.html
@@ -0,0 +1,21 @@
+{{define "index"}}{{template "pagehead" .}}
+<h1 class="home">repositories</h1>
+{{if .Repos}}
+<table class="repos">
+  {{range .Repos}}
+  <tr>
+    <td class="name"><a href="/{{.Name}}">{{.Name}}</a></td>
+    <td class="desc">{{.Description}}</td>
+    <td class="when">{{if .Empty}}empty{{else}}{{ago .LastCommit}}{{end}}</td>
+  </tr>
+  {{end}}
+</table>
+{{else}}
+<div class="empty">
+  <p>No repositories yet. The owner creates one by pushing to it:</p>
+  <pre>git remote add frieren {{.Base}}/myrepo.git
+git push frieren master</pre>
+  <p>git will ask for credentials — any username, the owner token as the password.</p>
+</div>
+{{end}}
+{{template "pagefoot" .}}{{end}}
diff --git a/templates/notfound.html b/templates/notfound.html
new file mode 100644
index 0000000..2cd8e46
--- /dev/null
+++ b/templates/notfound.html
@@ -0,0 +1,6 @@
+{{define "notfound"}}{{template "pagehead" .}}
+<div class="empty">
+  <h1>404</h1>
+  <p>Nothing here. <a href="/">Back to the repositories.</a></p>
+</div>
+{{template "pagefoot" .}}{{end}}
diff --git a/templates/parts.html b/templates/parts.html
new file mode 100644
index 0000000..645a145
--- /dev/null
+++ b/templates/parts.html
@@ -0,0 +1,35 @@
+{{define "pagehead"}}<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{.Title}}</title>
+<link rel="stylesheet" href="/static/style.css">
+<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>❄</text></svg>">
+</head>
+<body>
+<header class="site">
+  <a class="wordmark" href="/">frieren</a>
+</header>
+<main>
+{{end}}
+
+{{define "pagefoot"}}
+</main>
+<footer class="site">served by <a href="https://github.com/justin06lee/frieren">frieren</a> — clone freely; only the owner writes.</footer>
+</body>
+</html>{{end}}
+
+{{define "repohead"}}
+<div class="repohead">
+  <h1><a href="/">~</a> / <a href="/{{.Repo.Name}}">{{.Repo.Name}}</a></h1>
+  {{if .Repo.Description}}<p class="desc">{{.Repo.Description}}</p>{{end}}
+  <nav class="tabs">
+    <a {{if eq .Tab "files"}}class="on"{{end}} href="/{{.Repo.Name}}">files</a>
+    <a {{if eq .Tab "commits"}}class="on"{{end}} href="/{{.Repo.Name}}/commits">commits</a>
+    <a {{if eq .Tab "refs"}}class="on"{{end}} href="/{{.Repo.Name}}/refs">refs</a>
+    {{if .Ref}}<span class="ref" title="ref">{{.Ref}}</span>{{end}}
+    <code class="clone">git clone {{.CloneURL}}</code>
+  </nav>
+</div>
+{{end}}
diff --git a/templates/refs.html b/templates/refs.html
new file mode 100644
index 0000000..1ab63c5
--- /dev/null
+++ b/templates/refs.html
@@ -0,0 +1,30 @@
+{{define "refs"}}{{template "pagehead" .}}
+{{template "repohead" .}}
+{{$p := .}}
+<h2>branches</h2>
+{{if .Branches}}
+<table class="commits">
+  {{range .Branches}}
+  <tr>
+    <td class="name"><a href="/{{$p.Repo.Name}}/tree/{{pesc .Name}}">{{.Name}}</a></td>
+    <td class="hash"><a href="/{{$p.Repo.Name}}/commits/{{pesc .Name}}">{{.Short}}</a></td>
+    <td class="subject">{{.Subject}}</td>
+    <td class="when">{{ago .When}}</td>
+  </tr>
+  {{end}}
+</table>
+{{else}}<p class="empty">none</p>{{end}}
+<h2>tags</h2>
+{{if .Tags}}
+<table class="commits">
+  {{range .Tags}}
+  <tr>
+    <td class="name">{{.Name}}</td>
+    <td class="hash">{{.Short}}</td>
+    <td class="subject">{{.Subject}}</td>
+    <td class="when">{{ago .When}}</td>
+  </tr>
+  {{end}}
+</table>
+{{else}}<p class="empty">none</p>{{end}}
+{{template "pagefoot" .}}{{end}}
diff --git a/templates/repo.html b/templates/repo.html
new file mode 100644
index 0000000..7cb2a33
--- /dev/null
+++ b/templates/repo.html
@@ -0,0 +1,32 @@
+{{define "repo"}}{{template "pagehead" .}}
+{{template "repohead" .}}
+{{if .Repo.Empty}}
+<div class="empty">
+  <p>This repository is empty. The owner fills it with:</p>
+  <pre>git remote add frieren {{.CloneURL}}
+git push frieren master</pre>
+</div>
+{{else}}
+<table class="tree">
+  {{$p := .}}
+  {{range .Entries}}
+  <tr>
+    <td class="mode">{{.Mode}}</td>
+    {{if eq .Type "tree"}}
+    <td class="name dir"><a href="/{{$p.Repo.Name}}/tree/{{pesc $p.Ref}}/{{.Name}}">{{.Name}}/</a></td>
+    <td class="size"></td>
+    {{else}}
+    <td class="name"><a href="/{{$p.Repo.Name}}/blob/{{pesc $p.Ref}}/{{.Name}}">{{.Name}}</a></td>
+    <td class="size">{{size .Size}}</td>
+    {{end}}
+  </tr>
+  {{end}}
+</table>
+{{if .ReadmeName}}
+<section class="readme">
+  <h2>{{.ReadmeName}}</h2>
+  <pre>{{.Readme}}</pre>
+</section>
+{{end}}
+{{end}}
+{{template "pagefoot" .}}{{end}}
diff --git a/templates/tree.html b/templates/tree.html
new file mode 100644
index 0000000..b9af223
--- /dev/null
+++ b/templates/tree.html
@@ -0,0 +1,20 @@
+{{define "tree"}}{{template "pagehead" .}}
+{{template "repohead" .}}
+{{$p := .}}
+<p class="crumbs"><a href="/{{.Repo.Name}}">{{.Repo.Name}}</a>{{range .Crumbs}} / <a href="/{{$p.Repo.Name}}/tree/{{pesc $p.Ref}}/{{.Path}}">{{.Name}}</a>{{end}}</p>
+<table class="tree">
+  {{$base := ""}}{{if .Dir}}{{$base = printf "%s/" .Dir}}{{end}}
+  {{range .Entries}}
+  <tr>
+    <td class="mode">{{.Mode}}</td>
+    {{if eq .Type "tree"}}
+    <td class="name dir"><a href="/{{$p.Repo.Name}}/tree/{{pesc $p.Ref}}/{{$base}}{{.Name}}">{{.Name}}/</a></td>
+    <td class="size"></td>
+    {{else}}
+    <td class="name"><a href="/{{$p.Repo.Name}}/blob/{{pesc $p.Ref}}/{{$base}}{{.Name}}">{{.Name}}</a></td>
+    <td class="size">{{size .Size}}</td>
+    {{end}}
+  </tr>
+  {{end}}
+</table>
+{{template "pagefoot" .}}{{end}}
diff --git a/web.go b/web.go
new file mode 100644
index 0000000..0e22055
--- /dev/null
+++ b/web.go
@@ -0,0 +1,372 @@
+package main
+
+import (
+	"bytes"
+	"embed"
+	"fmt"
+	"html/template"
+	"log"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+)
+
+//go:embed templates/*.html
+var templateFS embed.FS
+
+//go:embed static/style.css
+var styleSheet []byte
+
+var funcs = template.FuncMap{
+	"pesc": url.PathEscape,
+	"ago":  timeAgo,
+	"size": byteSize,
+}
+
+var pages = template.Must(template.New("").Funcs(funcs).ParseFS(templateFS, "templates/*.html"))
+
+func timeAgo(t time.Time) string {
+	if t.IsZero() {
+		return ""
+	}
+	d := time.Since(t)
+	switch {
+	case d < time.Minute:
+		return "just now"
+	case d < time.Hour:
+		return fmt.Sprintf("%dm ago", int(d.Minutes()))
+	case d < 24*time.Hour:
+		return fmt.Sprintf("%dh ago", int(d.Hours()))
+	case d < 30*24*time.Hour:
+		return fmt.Sprintf("%dd ago", int(d.Hours()/24))
+	default:
+		return t.Format("Jan 2, 2006")
+	}
+}
+
+func byteSize(n int64) string {
+	switch {
+	case n < 0:
+		return ""
+	case n < 1024:
+		return fmt.Sprintf("%d B", n)
+	case n < 1<<20:
+		return fmt.Sprintf("%.1f KB", float64(n)/1024)
+	default:
+		return fmt.Sprintf("%.1f MB", float64(n)/(1<<20))
+	}
+}
+
+func (srv *Server) render(w http.ResponseWriter, name string, data any) {
+	var buf bytes.Buffer
+	if err := pages.ExecuteTemplate(&buf, name, data); err != nil {
+		log.Printf("render %s: %v", name, err)
+		http.Error(w, "template error", http.StatusInternalServerError)
+		return
+	}
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
+	buf.WriteTo(w)
+}
+
+func (srv *Server) styleCSS(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Content-Type", "text/css; charset=utf-8")
+	w.Header().Set("Cache-Control", "max-age=3600")
+	w.Write(styleSheet)
+}
+
+func baseURL(r *http.Request) string {
+	scheme := "http"
+	if r.TLS != nil {
+		scheme = "https"
+	}
+	if p := r.Header.Get("X-Forwarded-Proto"); p != "" {
+		scheme = p
+	}
+	return scheme + "://" + r.Host
+}
+
+// page is the data every template receives.
+type page struct {
+	Title    string
+	Repo     *RepoInfo
+	Ref      string
+	Path     string
+	CloneURL string
+	Tab      string
+	Base     string
+}
+
+func (srv *Server) newPage(r *http.Request, repo *RepoInfo, ref, tab string) page {
+	p := page{Title: "frieren", Tab: tab, Base: baseURL(r)}
+	if repo != nil {
+		p.Repo = repo
+		p.Ref = ref
+		p.Title = repo.Name + " · frieren"
+		p.CloneURL = baseURL(r) + "/" + repo.Name + ".git"
+	}
+	return p
+}
+
+// openRepo loads the repo named in the route or writes a 404.
+func (srv *Server) openRepo(w http.ResponseWriter, r *http.Request) *RepoInfo {
+	info, err := srv.Store.open(repoParam(r))
+	if err != nil {
+		srv.notFound(w, r)
+		return nil
+	}
+	return info
+}
+
+func (srv *Server) notFound(w http.ResponseWriter, r *http.Request) {
+	w.WriteHeader(http.StatusNotFound)
+	srv.render(w, "notfound", page{Title: "not found · frieren"})
+}
+
+func (srv *Server) indexPage(w http.ResponseWriter, r *http.Request) {
+	srv.render(w, "index", struct {
+		page
+		Repos []*RepoInfo
+	}{srv.newPage(r, nil, "", ""), srv.Store.list()})
+}
+
+func (srv *Server) repoPage(w http.ResponseWriter, r *http.Request) {
+	repo := srv.openRepo(w, r)
+	if repo == nil {
+		return
+	}
+	data := struct {
+		page
+		Entries    []TreeEntry
+		ReadmeName string
+		Readme     string
+	}{page: srv.newPage(r, repo, repo.Default, "files")}
+	if !repo.Empty {
+		entries, err := srv.Store.lsTree(r.Context(), repo.Name, repo.Default, "")
+		if err == nil {
+			data.Entries = entries
+		}
+		if name, body := srv.Store.readme(r.Context(), repo.Name, repo.Default); name != "" && !isBinary(body) {
+			data.ReadmeName = name
+			data.Readme = string(body)
+		}
+	}
+	srv.render(w, "repo", data)
+}
+
+type crumb struct {
+	Name string
+	Path string
+}
+
+func crumbs(path string) []crumb {
+	if path == "" {
+		return nil
+	}
+	parts := strings.Split(path, "/")
+	out := make([]crumb, len(parts))
+	for i, part := range parts {
+		out[i] = crumb{Name: part, Path: strings.Join(parts[:i+1], "/")}
+	}
+	return out
+}
+
+func (srv *Server) treePage(w http.ResponseWriter, r *http.Request) {
+	repo := srv.openRepo(w, r)
+	if repo == nil {
+		return
+	}
+	ref, path := r.PathValue("ref"), r.PathValue("path")
+	if !validRef(ref) || !validPath(path) {
+		srv.notFound(w, r)
+		return
+	}
+	entries, err := srv.Store.lsTree(r.Context(), repo.Name, ref, path)
+	if err != nil {
+		srv.notFound(w, r)
+		return
+	}
+	srv.render(w, "tree", struct {
+		page
+		Entries []TreeEntry
+		Crumbs  []crumb
+		Dir     string
+	}{srv.newPage(r, repo, ref, "files"), entries, crumbs(path), path})
+}
+
+func isBinary(b []byte) bool {
+	if len(b) > 8000 {
+		b = b[:8000]
+	}
+	return bytes.IndexByte(b, 0) >= 0
+}
+
+func (srv *Server) blobPage(w http.ResponseWriter, r *http.Request) {
+	repo := srv.openRepo(w, r)
+	if repo == nil {
+		return
+	}
+	ref, path := r.PathValue("ref"), r.PathValue("path")
+	if !validRef(ref) || !validPath(path) || path == "" {
+		srv.notFound(w, r)
+		return
+	}
+	blob, err := srv.Store.catBlob(r.Context(), repo.Name, ref, path)
+	if err != nil {
+		srv.notFound(w, r)
+		return
+	}
+	all := crumbs(path)
+	data := struct {
+		page
+		Parents   []crumb
+		FileName  string
+		RawPath   string
+		Lines     []string
+		Bytes     int64
+		Binary    bool
+		Truncated bool
+	}{
+		page:     srv.newPage(r, repo, ref, "files"),
+		Parents:  all[:len(all)-1],
+		FileName: all[len(all)-1].Name,
+		RawPath:  path,
+		Bytes:    int64(len(blob)),
+	}
+	switch {
+	case isBinary(blob):
+		data.Binary = true
+	case len(blob) > maxBlobBytes:
+		data.Truncated = true
+	default:
+		data.Lines = strings.Split(strings.TrimSuffix(string(blob), "\n"), "\n")
+	}
+	srv.render(w, "blob", data)
+}
+
+func (srv *Server) rawFile(w http.ResponseWriter, r *http.Request) {
+	repo := srv.openRepo(w, r)
+	if repo == nil {
+		return
+	}
+	ref, path := r.PathValue("ref"), r.PathValue("path")
+	if !validRef(ref) || !validPath(path) || path == "" {
+		http.NotFound(w, r)
+		return
+	}
+	blob, err := srv.Store.catBlob(r.Context(), repo.Name, ref, path)
+	if err != nil {
+		http.NotFound(w, r)
+		return
+	}
+	// Never let the browser interpret repository content as HTML.
+	w.Header().Set("X-Content-Type-Options", "nosniff")
+	if isBinary(blob) {
+		w.Header().Set("Content-Type", "application/octet-stream")
+	} else {
+		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+	}
+	w.Write(blob)
+}
+
+func (srv *Server) commitsPage(w http.ResponseWriter, r *http.Request) {
+	repo := srv.openRepo(w, r)
+	if repo == nil {
+		return
+	}
+	ref := r.PathValue("ref")
+	if ref == "" {
+		ref = repo.Default
+	}
+	if !validRef(ref) {
+		srv.notFound(w, r)
+		return
+	}
+	var commits []Commit
+	if !repo.Empty {
+		var err error
+		commits, err = srv.Store.log(r.Context(), repo.Name, ref, 100)
+		if err != nil {
+			srv.notFound(w, r)
+			return
+		}
+	}
+	srv.render(w, "commits", struct {
+		page
+		Commits []Commit
+	}{srv.newPage(r, repo, ref, "commits"), commits})
+}
+
+type diffLine struct {
+	Class string
+	Text  string
+}
+
+func classifyPatch(patch string) []diffLine {
+	if patch == "" {
+		return nil
+	}
+	lines := strings.Split(strings.TrimSuffix(patch, "\n"), "\n")
+	out := make([]diffLine, len(lines))
+	for i, l := range lines {
+		class := "ctx"
+		switch {
+		case strings.HasPrefix(l, "diff --git") || strings.HasPrefix(l, "index ") ||
+			strings.HasPrefix(l, "new file") || strings.HasPrefix(l, "deleted file") ||
+			strings.HasPrefix(l, "similarity ") || strings.HasPrefix(l, "rename "):
+			class = "meta"
+		case strings.HasPrefix(l, "+++") || strings.HasPrefix(l, "---"):
+			class = "file"
+		case strings.HasPrefix(l, "@@"):
+			class = "hunk"
+		case strings.HasPrefix(l, "+"):
+			class = "add"
+		case strings.HasPrefix(l, "-"):
+			class = "del"
+		}
+		out[i] = diffLine{Class: class, Text: l}
+	}
+	return out
+}
+
+func (srv *Server) commitPage(w http.ResponseWriter, r *http.Request) {
+	repo := srv.openRepo(w, r)
+	if repo == nil {
+		return
+	}
+	hash := r.PathValue("hash")
+	if !validRef(hash) {
+		srv.notFound(w, r)
+		return
+	}
+	commit, err := srv.Store.commit(r.Context(), repo.Name, hash)
+	if err != nil {
+		srv.notFound(w, r)
+		return
+	}
+	patch, truncated, err := srv.Store.patch(r.Context(), repo.Name, commit.Hash)
+	if err != nil {
+		srv.notFound(w, r)
+		return
+	}
+	srv.render(w, "commit", struct {
+		page
+		Commit    *Commit
+		Diff      []diffLine
+		Truncated bool
+	}{srv.newPage(r, repo, "", "commits"), commit, classifyPatch(patch), truncated})
+}
+
+func (srv *Server) refsPage(w http.ResponseWriter, r *http.Request) {
+	repo := srv.openRepo(w, r)
+	if repo == nil {
+		return
+	}
+	branches, _ := srv.Store.refs(r.Context(), repo.Name, "heads")
+	tags, _ := srv.Store.refs(r.Context(), repo.Name, "tags")
+	srv.render(w, "refs", struct {
+		page
+		Branches []Ref
+		Tags     []Ref
+	}{srv.newPage(r, repo, "", "refs"), branches, tags})
+}