penguin/flatgit

flatgit showcase

commit e6bba11eb9a62fb3861010669a838fded9d3d0ac

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-06-18T20:08:32Z
subjectFirst draft
commit e6bba11eb9a62fb3861010669a838fded9d3d0ac
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-06-18T20:08:32Z

    First draft
---
 .github/workflows/ci.yml            |  17 ++
 .gitignore                          |   8 +
 Makefile                            |  47 +++++
 README.md                           | 139 +++++++++++++
 build.env                           |   1 +
 cmd/flatgit/main.go                 | 180 ++++++++++++++++
 dockerfiles/dev.Dockerfile          |   4 +
 examples/tinyproxy.json             |  26 +++
 go.mod                              |   3 +
 internal/buildinfo/buildinfo_gen.go |   7 +
 internal/config/config.go           | 176 ++++++++++++++++
 internal/config/config_test.go      |  19 ++
 internal/gitcmd/gitcmd.go           | 116 +++++++++++
 internal/jobqueue/jobqueue.go       |  89 ++++++++
 internal/render/manifest.go         | 104 ++++++++++
 internal/render/render.go           | 400 ++++++++++++++++++++++++++++++++++++
 internal/render/templates.go        | 120 +++++++++++
 internal/server/server.go           | 100 +++++++++
 internal/webhook/webhook.go         | 115 +++++++++++
 resources/robots.txt                |   5 +
 20 files changed, 1676 insertions(+)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..4e78e38
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,17 @@
+name: build
+
+on:
+  push:
+  pull_request:
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-go@v5
+        with:
+          go-version: '1.26.x'
+      - run: go test ./...
+      - run: go vet ./...
+      - run: go build ./cmd/flatgit
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7c0e8d3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+/flatgit
+/dist/
+out/
+bin/
+*.test
+*.out
+*.swp
+.DS_Store
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..54020d2
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,47 @@
+include build.env
+-include build.env.work
+export
+
+OUT_DIR := out
+BIN_DIR := bin
+
+VERSION ?= dev
+GIT_REV := $(shell git rev-parse HEAD)
+
+BUILDINFO_FILE := internal/buildinfo/buildinfo_gen.go
+
+all: test build
+
+.buildinfo:
+	@mkdir -p $(dir $(BUILDINFO_FILE))
+	@printf '%s\n' \
+		'package buildinfo' \
+		'' \
+		'const (' \
+		'    Version     = "$(VERSION)"' \
+		'    GitRevision = "$(GIT_REV)"' \
+		'    Timestamp   = "'$$(TZ=UTC date +%Y%m%d.%H%M%S)'"' \
+		')' \
+	> $(BUILDINFO_FILE)
+
+build: .buildinfo
+	mkdir -p "$(OUT_DIR)" "$(BIN_DIR)"
+	go build -o "$(BIN_DIR)/flatgit" ./cmd/flatgit
+
+test:
+	go test ./...
+
+fmt:
+	gofmt -w ./cmd ./internal
+
+run: build
+	$(BIN_DIR)/flatgit daemon -c examples/tinyproxy.json
+
+vet:
+	go vet ./...
+
+clean:
+	rm -f "$(BIN_DIR)/flatgit"
+	rm -rf dist
+
+.PHONY: all build test fmt vet clean run
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b262594
--- /dev/null
+++ b/README.md
@@ -0,0 +1,139 @@
+# flatgit
+
+`flatgit` is a static Git web generator with webhook-driven updates and an optional built-in file server.
+
+## Philosophy
+
+Git repo in, static browsable site out.
+
+```txt
+webhook -> git clone/fetch --mirror -> render HTML + JSON -> serve static files
+```
+
+It currently shells out to the real `git` binary.
+
+Since Git already knows how to deal with packed refs, tags, bare repositories, weird histories, and future compatibility.
+The main focus is on rendering.
+
+HTML should be served by proper http server if possible. The built-in server is fine but basic.
+
+## Current status
+
+- `flatgit render` one-shot renderer
+- `flatgit serve` static file server
+- `flatgit daemon` webhook + render workers + static serving
+- mirror clone/fetch using `git clone --mirror` and `git remote update --prune`
+- simple HTML pages:
+  - summary
+  - refs
+  - log
+  - tree listing
+  - blob view
+  - commit patch view
+- JSON files:
+  - `flatgit.json`
+  - `refs.json`
+  - `commits.json`
+  - `tree.json`
+  - `commit/<sha>.json`
+- Gitea/GitHub-style webhook endpoints:
+  - `POST /webhook/gitea`
+  - `POST /webhook/github`
+
+## Build
+
+```sh
+make clean build
+```
+
+## Config
+
+See [`examples/tinyproxy.json`](examples/tinyproxy.json):
+
+```json
+{
+  "addr": ":8080",
+  "data_dir": "/var/lib/flatgit",
+  "webhook": {
+    "secret": "change-me"
+  },
+  "git": {
+    "command": "git",
+    "clone_timeout": "2m",
+    "fetch_timeout": "2m"
+  },
+  "render": {
+    "workers": 2,
+    "max_commits": 500
+  },
+  "repos": [
+    {
+      "owner": "penguin",
+      "name": "test-repo",
+      "url": "http://gitea-http.gitea.svc.cluster.local:3000/penguin/test-repo.git",
+      "default_branch": "main"
+    }
+  ]
+}
+```
+
+Derived paths:
+
+```txt
+/var/lib/flatgit/repos/penguin_test-repo.git
+/var/lib/flatgit/www/penguin_test-repo
+```
+
+## Commands
+
+Render all configured repos:
+
+```sh
+flatgit render -c examples/flatgit.json
+```
+
+Render one repo:
+
+```sh
+flatgit render -c examples/flatgit.json -repo penguin_test-repo
+```
+
+Serve an already-rendered web root:
+
+```sh
+flatgit serve -root /var/lib/flatgit/www -addr :8080
+```
+
+Run the daemon:
+
+```sh
+flatgit daemon -c examples/flatgit.json
+```
+
+The daemon serves:
+
+```txt
+/                       static web root
+/healthz                health check
+/webhook/gitea          Gitea webhook endpoint
+/webhook/github         GitHub webhook endpoint
+```
+
+## Webhook signature
+
+If `webhook.secret` is non-empty, the handler accepts either:
+
+- `X-Hub-Signature-256: sha256=<hex-hmac>`
+- `X-Gitea-Signature: <hex-hmac>`
+
+The HMAC is SHA-256 over the raw request body.
+
+## TODO
+
+- Add README rendering
+- Add syntax highlighting, probably with a tiny vendored/highlight-free first pass
+- Improve branch/tag URL escaping
+- Render per-branch trees instead of only the default branch
+- Add archive links
+- Add repo index page at the web-root
+- Add tests using temporary local Git repos
diff --git a/build.env b/build.env
new file mode 100644
index 0000000..4663905
--- /dev/null
+++ b/build.env
@@ -0,0 +1 @@
+APT_PROXY=
diff --git a/cmd/flatgit/main.go b/cmd/flatgit/main.go
new file mode 100644
index 0000000..98eb00c
--- /dev/null
+++ b/cmd/flatgit/main.go
@@ -0,0 +1,180 @@
+package main
+
+import (
+	"context"
+	"errors"
+	"flag"
+	"fmt"
+	"log/slog"
+	"net/http"
+	"os"
+	"os/signal"
+	"strings"
+	"syscall"
+
+	"github.com/tgckpg/flatgit/internal/buildinfo"
+	"github.com/tgckpg/flatgit/internal/config"
+	"github.com/tgckpg/flatgit/internal/gitcmd"
+	"github.com/tgckpg/flatgit/internal/jobqueue"
+	"github.com/tgckpg/flatgit/internal/render"
+	"github.com/tgckpg/flatgit/internal/server"
+	"github.com/tgckpg/flatgit/internal/webhook"
+)
+
+func main() {
+	log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{}))
+	if err := run(log, os.Args); err != nil {
+		log.Error("flatgit failed", "err", err)
+		os.Exit(1)
+	}
+}
+
+func run(log *slog.Logger, args []string) error {
+	if len(args) < 2 {
+		usage(args[0])
+		return errors.New("missing command")
+	}
+
+	switch args[1] {
+	case "render":
+		return renderCmd(log, args[2:])
+	case "serve":
+		return serveCmd(log, args[2:])
+	case "daemon":
+		return daemonCmd(log, args[2:])
+	case "version", "-v", "--version":
+		fmt.Printf("%s (%s)\n", buildinfo.Version, buildinfo.Timestamp)
+		return nil
+	case "help", "-h", "--help":
+		usage(args[0])
+		return nil
+	default:
+		usage(args[0])
+		return fmt.Errorf("unknown command %q", args[1])
+	}
+}
+
+func renderCmd(log *slog.Logger, args []string) error {
+	fs := flag.NewFlagSet("render", flag.ExitOnError)
+	cfgPath := fs.String("c", "flatgit.json", "config file")
+	repoName := fs.String("repo", "", "repo name to render; empty renders all repos")
+	fetch := fs.Bool("fetch", true, "clone/fetch before rendering")
+	if err := fs.Parse(args); err != nil {
+		return err
+	}
+	cfg, err := config.Load(*cfgPath)
+	if err != nil {
+		return err
+	}
+	ctx := context.Background()
+	return renderConfigured(ctx, log, cfg, *repoName, *fetch)
+}
+
+func serveCmd(log *slog.Logger, args []string) error {
+	fs := flag.NewFlagSet("serve", flag.ExitOnError)
+	cfgPath := fs.String("c", "", "config file; optional if -root is set")
+	addr := fs.String("addr", ":8080", "listen address")
+	root := fs.String("root", "", "static root")
+	if err := fs.Parse(args); err != nil {
+		return err
+	}
+	if *cfgPath != "" {
+		cfg, err := config.Load(*cfgPath)
+		if err != nil {
+			return err
+		}
+		if *root == "" {
+			*root = cfg.WebRoot()
+		}
+		if *addr == ":8080" && cfg.Addr != "" {
+			*addr = cfg.Addr
+		}
+	}
+	if *root == "" {
+		return errors.New("serve needs -root or -c")
+	}
+	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+	defer stop()
+
+	return server.ListenAndServe(ctx, server.Options{Addr: *addr, Root: *root, Logger: log})
+}
+
+func daemonCmd(log *slog.Logger, args []string) error {
+	fs := flag.NewFlagSet("daemon", flag.ExitOnError)
+	cfgPath := fs.String("c", "flatgit.json", "config file")
+	renderOnStart := fs.Bool("render-on-start", true, "render all repos on startup")
+	if err := fs.Parse(args); err != nil {
+		return err
+	}
+	cfg, err := config.Load(*cfgPath)
+	if err != nil {
+		return err
+	}
+
+	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+	defer stop()
+
+	git := gitcmd.New(cfg.Git.Command)
+	r := render.New(git, cfg.PublicURL, cfg.Render.MaxCommits)
+	q := jobqueue.New(1024, log)
+	q.Start(ctx, cfg.Render.Workers, func(ctx context.Context, name string) error {
+		repo, ok := cfg.RepoByName(name)
+		if !ok {
+			return fmt.Errorf("repo not found: %s", name)
+		}
+		if err := git.EnsureMirror(ctx, *repo, cfg.CloneTimeout(), cfg.FetchTimeout()); err != nil {
+			return err
+		}
+		return r.RenderRepo(ctx, *repo)
+	})
+
+	if *renderOnStart {
+		for _, repo := range cfg.Repos {
+			q.Enqueue(repo.Name)
+		}
+	}
+
+	wh := &webhook.Handler{Config: cfg, Queue: q, Logger: log}
+	return server.ListenAndServe(ctx, server.Options{
+		Addr:   cfg.Addr,
+		Root:   cfg.WebRoot(),
+		Logger: log,
+		WebhookMux: func(mux *http.ServeMux) {
+			wh.Register(mux)
+		},
+	})
+}
+
+func renderConfigured(ctx context.Context, log *slog.Logger, cfg *config.Config, repoName string, fetch bool) error {
+	git := gitcmd.New(cfg.Git.Command)
+	r := render.New(git, cfg.PublicURL, cfg.Render.MaxCommits)
+	for i := range cfg.Repos {
+		repo := cfg.Repos[i]
+		if repoName != "" && !strings.EqualFold(repo.Name, repoName) && !strings.EqualFold(repo.Slug(), repoName) && !strings.EqualFold(repo.FullName(), repoName) {
+			continue
+		}
+		log.Info("rendering repo", "repo", repo.FullName())
+		if fetch {
+			if err := git.EnsureMirror(ctx, repo, cfg.CloneTimeout(), cfg.FetchTimeout()); err != nil {
+				return err
+			}
+		}
+		if err := r.RenderRepo(ctx, repo); err != nil {
+			return err
+		}
+		log.Info("rendered repo", "repo", repo.FullName(), "output", repo.OutputDir)
+	}
+	return nil
+}
+
+func usage(name string) {
+	fmt.Fprintf(os.Stderr, `flatgit - static Git renderer
+
+Usage:
+  %[1]s render [-c flatgit.json] [-repo name] [-fetch=true]
+  %[1]s serve  [-c flatgit.json] [-root /var/lib/flatgit/www] [-addr :8080]
+  %[1]s daemon [-c flatgit.json] [-render-on-start=true]
+  %[1]s version
+
+`, name)
+}
diff --git a/dockerfiles/dev.Dockerfile b/dockerfiles/dev.Dockerfile
new file mode 100644
index 0000000..e0e141b
--- /dev/null
+++ b/dockerfiles/dev.Dockerfile
@@ -0,0 +1,4 @@
+FROM golang:1.26-alpine
+RUN apk add --no-cache git make
+WORKDIR /src
+CMD ["sh"]
diff --git a/examples/tinyproxy.json b/examples/tinyproxy.json
new file mode 100644
index 0000000..5113657
--- /dev/null
+++ b/examples/tinyproxy.json
@@ -0,0 +1,26 @@
+{
+  "addr": ":8080",
+  "data_dir": "./out/datadir",
+  "public_url": "https://git.example.com",
+  "webhook": {
+    "secret": "change-me"
+  },
+  "git": {
+    "command": "git",
+    "clone_timeout": "2m",
+    "fetch_timeout": "2m"
+  },
+  "render": {
+    "workers": 2,
+    "max_commits": 500
+  },
+  "repos": [
+    {
+      "owner": "penguin",
+      "name": "tinyproxy",
+      "url": "https://github.com/tgckpg/tinyproxy",
+      "default_branch": "main",
+      "description": "tinyporxy source tree"
+    }
+  ]
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..12611ab
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module github.com/tgckpg/flatgit
+
+go 1.25
diff --git a/internal/buildinfo/buildinfo_gen.go b/internal/buildinfo/buildinfo_gen.go
new file mode 100644
index 0000000..ecb2443
--- /dev/null
+++ b/internal/buildinfo/buildinfo_gen.go
@@ -0,0 +1,7 @@
+package buildinfo
+
+const (
+    Version     = "dev"
+    GitRevision = "e9ca3ccc17250e570d1f9ba87f5c98acbb761637"
+    Timestamp   = "20260618.200301"
+)
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..d0eaa96
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,176 @@
+package config
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+)
+
+type Config struct {
+	Addr      string        `json:"addr"`
+	DataDir   string        `json:"data_dir"`
+	PublicURL string        `json:"public_url"`
+	Webhook   WebhookConfig `json:"webhook"`
+	Git       GitConfig     `json:"git"`
+	Render    RenderConfig  `json:"render"`
+	Repos     []Repo        `json:"repos"`
+}
+
+type WebhookConfig struct {
+	Secret string `json:"secret"`
+}
+
+type GitConfig struct {
+	Command      string `json:"command"`
+	CloneTimeout string `json:"clone_timeout"`
+	FetchTimeout string `json:"fetch_timeout"`
+}
+
+type RenderConfig struct {
+	Workers    int `json:"workers"`
+	MaxCommits int `json:"max_commits"`
+}
+
+type Repo struct {
+	Name          string `json:"name"`
+	Owner         string `json:"owner"`
+	URL           string `json:"url"`
+	DefaultBranch string `json:"default_branch"`
+	MirrorDir     string `json:"mirror_dir"`
+	OutputDir     string `json:"output_dir"`
+	Description   string `json:"description"`
+}
+
+func Load(path string) (*Config, error) {
+	b, err := os.ReadFile(path)
+	if err != nil {
+		return nil, err
+	}
+
+	var cfg Config
+	if err := json.Unmarshal(b, &cfg); err != nil {
+		return nil, fmt.Errorf("parse %s: %w", path, err)
+	}
+	if err := cfg.ApplyDefaults(); err != nil {
+		return nil, err
+	}
+	return &cfg, nil
+}
+
+func (c *Config) ApplyDefaults() error {
+	if c.Addr == "" {
+		c.Addr = ":8080"
+	}
+	if c.DataDir == "" {
+		c.DataDir = "/var/lib/flatgit"
+	}
+	if c.Git.Command == "" {
+		c.Git.Command = "git"
+	}
+	if c.Git.CloneTimeout == "" {
+		c.Git.CloneTimeout = "2m"
+	}
+	if c.Git.FetchTimeout == "" {
+		c.Git.FetchTimeout = "2m"
+	}
+	if c.Render.Workers <= 0 {
+		c.Render.Workers = 1
+	}
+	if c.Render.MaxCommits <= 0 {
+		c.Render.MaxCommits = 500
+	}
+
+	seen := make(map[string]bool, len(c.Repos))
+	for i := range c.Repos {
+		r := &c.Repos[i]
+		if r.Name == "" {
+			return errors.New("repo missing name")
+		}
+		if seen[r.Name] {
+			return fmt.Errorf("duplicate repo name %q", r.Name)
+		}
+		seen[r.Name] = true
+
+		r.Name = cleanName(r.Name)
+		if r.Owner != "" {
+			r.Owner = cleanName(r.Owner)
+		}
+		if r.DefaultBranch == "" {
+			r.DefaultBranch = "main"
+		}
+		if r.MirrorDir == "" {
+			r.MirrorDir = filepath.Join(c.DataDir, "repos", r.Slug()+".git")
+		}
+		if r.OutputDir == "" {
+			r.OutputDir = filepath.Join(c.DataDir, "www", r.Slug())
+		}
+	}
+	return nil
+}
+
+func (c *Config) CloneTimeout() time.Duration {
+	return parseDuration(c.Git.CloneTimeout, 2*time.Minute)
+}
+
+func (c *Config) FetchTimeout() time.Duration {
+	return parseDuration(c.Git.FetchTimeout, 2*time.Minute)
+}
+
+func (c *Config) WebRoot() string {
+	return filepath.Join(c.DataDir, "www")
+}
+
+func (c *Config) RepoByName(name string) (*Repo, bool) {
+	name = cleanName(name)
+	for i := range c.Repos {
+		if c.Repos[i].Name == name || c.Repos[i].Slug() == name || c.Repos[i].FullName() == name {
+			return &c.Repos[i], true
+		}
+	}
+	return nil, false
+}
+
+func (c *Config) RepoByWebhookName(fullName string) (*Repo, bool) {
+	fullName = strings.TrimSpace(fullName)
+	for i := range c.Repos {
+		r := &c.Repos[i]
+		if fullName == r.FullName() || fullName == r.Name || fullName == r.Slug() {
+			return r, true
+		}
+	}
+	return nil, false
+}
+
+func (r Repo) Slug() string {
+	if r.Owner == "" {
+		return cleanName(r.Name)
+	}
+	return cleanName(r.Owner + "_" + r.Name)
+}
+
+func (r Repo) FullName() string {
+	if r.Owner == "" {
+		return r.Name
+	}
+	return r.Owner + "/" + r.Name
+}
+
+func parseDuration(s string, fallback time.Duration) time.Duration {
+	d, err := time.ParseDuration(s)
+	if err != nil {
+		return fallback
+	}
+	return d
+}
+
+func cleanName(s string) string {
+	s = strings.TrimSpace(s)
+	s = strings.ReplaceAll(s, "\\", "_")
+	s = strings.ReplaceAll(s, "/", "_")
+	s = strings.ReplaceAll(s, "..", "_")
+	return s
+}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
new file mode 100644
index 0000000..73b1e97
--- /dev/null
+++ b/internal/config/config_test.go
@@ -0,0 +1,19 @@
+package config
+
+import "testing"
+
+func TestDefaults(t *testing.T) {
+	cfg := &Config{Repos: []Repo{{Name: "test-repo", Owner: "penguin"}}}
+	if err := cfg.ApplyDefaults(); err != nil {
+		t.Fatal(err)
+	}
+	if cfg.Addr != ":8080" {
+		t.Fatalf("addr = %q", cfg.Addr)
+	}
+	if cfg.Repos[0].Slug() != "penguin_test-repo" {
+		t.Fatalf("slug = %q", cfg.Repos[0].Slug())
+	}
+	if cfg.Repos[0].MirrorDir == "" || cfg.Repos[0].OutputDir == "" {
+		t.Fatalf("paths were not defaulted")
+	}
+}
diff --git a/internal/gitcmd/gitcmd.go b/internal/gitcmd/gitcmd.go
new file mode 100644
index 0000000..c1beb75
--- /dev/null
+++ b/internal/gitcmd/gitcmd.go
@@ -0,0 +1,116 @@
+package gitcmd
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"github.com/tgckpg/flatgit/internal/config"
+)
+
+type Runner struct {
+	GitCommand string
+}
+
+func New(command string) *Runner {
+	if command == "" {
+		command = "git"
+	}
+	return &Runner{GitCommand: command}
+}
+
+func (r *Runner) EnsureMirror(ctx context.Context, repo config.Repo, cloneTimeout, fetchTimeout time.Duration) error {
+	if repo.URL == "" {
+		return fmt.Errorf("repo %s has no url", repo.FullName())
+	}
+	lockPath := repo.MirrorDir + ".lock"
+	unlock, err := acquireLock(lockPath)
+	if err != nil {
+		return err
+	}
+	defer unlock()
+
+	if isGitDir(repo.MirrorDir) {
+		ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
+		defer cancel()
+		_, err := r.Output(ctx, repo.MirrorDir, "remote", "update", "--prune")
+		if err != nil {
+			return err
+		}
+		_, _ = r.Output(context.Background(), repo.MirrorDir, "pack-refs", "--all", "--prune")
+		return nil
+	}
+
+	if err := os.MkdirAll(filepath.Dir(repo.MirrorDir), 0o755); err != nil {
+		return err
+	}
+	tmp := repo.MirrorDir + ".tmp"
+	_ = os.RemoveAll(tmp)
+	ctx, cancel := context.WithTimeout(ctx, cloneTimeout)
+	defer cancel()
+	if _, err := r.Output(ctx, "", "clone", "--mirror", repo.URL, tmp); err != nil {
+		_ = os.RemoveAll(tmp)
+		return err
+	}
+	_ = os.RemoveAll(repo.MirrorDir)
+	if err := os.Rename(tmp, repo.MirrorDir); err != nil {
+		_ = os.RemoveAll(tmp)
+		return err
+	}
+	return nil
+}
+
+func (r *Runner) Output(ctx context.Context, dir string, args ...string) ([]byte, error) {
+	cmd := exec.CommandContext(ctx, r.GitCommand, args...)
+	cmd.Env = os.Environ()
+	if dir != "" {
+		cmd.Dir = dir
+	}
+	var stdout, stderr bytes.Buffer
+	cmd.Stdout = &stdout
+	cmd.Stderr = &stderr
+	if err := cmd.Run(); err != nil {
+		msg := strings.TrimSpace(stderr.String())
+		if msg == "" {
+			msg = err.Error()
+		}
+		return stdout.Bytes(), fmt.Errorf("git %s: %s", strings.Join(args, " "), msg)
+	}
+	return stdout.Bytes(), nil
+}
+
+func (r *Runner) Text(ctx context.Context, dir string, args ...string) (string, error) {
+	b, err := r.Output(ctx, dir, args...)
+	return string(b), err
+}
+
+func isGitDir(path string) bool {
+	st, err := os.Stat(filepath.Join(path, "objects"))
+	if err != nil || !st.IsDir() {
+		return false
+	}
+	st, err = os.Stat(filepath.Join(path, "refs"))
+	return err == nil && st.IsDir()
+}
+
+func acquireLock(path string) (func(), error) {
+	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+		return nil, err
+	}
+	f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
+	if err != nil {
+		if errors.Is(err, os.ErrExist) {
+			return nil, fmt.Errorf("repo lock exists: %s", path)
+		}
+		return nil, err
+	}
+	_, _ = fmt.Fprintf(f, "%d\n", os.Getpid())
+	_ = f.Close()
+	return func() { _ = os.Remove(path) }, nil
+}
diff --git a/internal/jobqueue/jobqueue.go b/internal/jobqueue/jobqueue.go
new file mode 100644
index 0000000..a315ff3
--- /dev/null
+++ b/internal/jobqueue/jobqueue.go
@@ -0,0 +1,89 @@
+package jobqueue
+
+import (
+	"context"
+	"log/slog"
+	"sync"
+)
+
+type Handler func(context.Context, string) error
+
+type Queue struct {
+	ch      chan string
+	mu      sync.Mutex
+	queued  map[string]bool
+	running map[string]bool
+	log     *slog.Logger
+}
+
+func New(size int, log *slog.Logger) *Queue {
+	if size <= 0 {
+		size = 128
+	}
+	if log == nil {
+		log = slog.Default()
+	}
+	return &Queue{
+		ch:      make(chan string, size),
+		queued:  make(map[string]bool),
+		running: make(map[string]bool),
+		log:     log,
+	}
+}
+
+func (q *Queue) Enqueue(name string) bool {
+	q.mu.Lock()
+	if q.queued[name] || q.running[name] {
+		q.mu.Unlock()
+		return false
+	}
+	q.queued[name] = true
+	q.mu.Unlock()
+
+	select {
+	case q.ch <- name:
+		return true
+	default:
+		q.mu.Lock()
+		delete(q.queued, name)
+		q.mu.Unlock()
+		return false
+	}
+}
+
+func (q *Queue) Start(ctx context.Context, workers int, h Handler) {
+	if workers <= 0 {
+		workers = 1
+	}
+	for i := 0; i < workers; i++ {
+		go q.worker(ctx, i, h)
+	}
+}
+
+func (q *Queue) worker(ctx context.Context, id int, h Handler) {
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		case name, ok := <-q.ch:
+			if !ok {
+				return
+			}
+			q.mu.Lock()
+			delete(q.queued, name)
+			q.running[name] = true
+			q.mu.Unlock()
+
+			q.log.Info("render job started", "worker", id, "repo", name)
+			if err := h(ctx, name); err != nil {
+				q.log.Error("render job failed", "worker", id, "repo", name, "err", err)
+			} else {
+				q.log.Info("render job finished", "worker", id, "repo", name)
+			}
+
+			q.mu.Lock()
+			delete(q.running, name)
+			q.mu.Unlock()
+		}
+	}
+}
diff --git a/internal/render/manifest.go b/internal/render/manifest.go
new file mode 100644
index 0000000..da6f7df
--- /dev/null
+++ b/internal/render/manifest.go
@@ -0,0 +1,104 @@
+package render
+
+import (
+	"time"
+
+	"github.com/tgckpg/flatgit/internal/config"
+)
+
+type Manifest struct {
+	Schema       string               `json:"schema"`
+	Generator    ManifestGenerator    `json:"generator"`
+	Repository   ManifestRepository   `json:"repository"`
+	Human        ManifestHumanRoutes  `json:"human"`
+	Machine      ManifestAPIRoutes    `json:"machine"`
+	Capabilities ManifestCapabilities `json:"capabilities"`
+	GeneratedAt  time.Time            `json:"generated_at"`
+}
+
+type ManifestGenerator struct {
+	Name    string `json:"name"`
+	Version string `json:"version"`
+}
+
+type ManifestRepository struct {
+	Name           string    `json:"name"`
+	FullName       string    `json:"fullname"`
+	Owner          string    `json:"owner,omitempty"`
+	Description    string    `json:"description,omitempty"`
+	DefaultBranch  string    `json:"default_branch"`
+	DefaultCommit  string    `json:"default_commit"`
+	DefaultRefSlug string    `json:"default_ref_slug"`
+	SitePath       string    `json:"site_path"`
+	GeneratedAt    time.Time `json:"generated_at"`
+}
+
+type ManifestHumanRoutes struct {
+	Index string `json:"index"`
+	Log   string `json:"log"`
+	Refs  string `json:"refs"`
+}
+
+type ManifestAPIRoutes struct {
+	Self    string `json:"self"`
+	Refs    string `json:"refs"`
+	Commits string `json:"commits"`
+	Tree    string `json:"tree"`
+	Commit  string `json:"commit"`
+	Blob    string `json:"blob"`
+	Raw     string `json:"raw"`
+}
+
+type ManifestCapabilities struct {
+	Refs         bool `json:"refs"`
+	Commits      bool `json:"commits"`
+	Trees        bool `json:"trees"`
+	BlobMetadata bool `json:"blob_metadata"`
+	RawBlobs     bool `json:"raw_blobs"`
+	Search       bool `json:"search"`
+	Archive      bool `json:"archive"`
+}
+
+func NewManifest(repo config.Repo, defaultBranch string, defaultCommit string) Manifest {
+	return Manifest{
+		Schema: "https://flatgit.dev/schema/repository.v1.json",
+		Generator: ManifestGenerator{
+			Name:    "flatgit",
+			Version: "dev",
+		},
+		Repository: ManifestRepository{
+			Name:           repo.Name,
+			FullName:       repo.FullName(),
+			Owner:          repo.Owner,
+			Description:    repo.Description,
+			DefaultBranch:  defaultBranch,
+			DefaultCommit:  defaultCommit,
+			DefaultRefSlug: refSlug(repo.DefaultBranch),
+			SitePath:       "/" + repo.Slug() + "/",
+		},
+		Human: ManifestHumanRoutes{
+			Index: "./index.html",
+			Log:   "./log.html",
+			Refs:  "./refs.html",
+		},
+		Machine: ManifestAPIRoutes{
+			Self:    "./flatgit.json",
+			Refs:    "./refs.json",
+			Commits: "./commits.json",
+			Tree:    "./tree/{ref}.json",
+			Commit:  "./commit/{sha}.json",
+			Blob:    "./blob/{ref}/{path}.json",
+			Raw:     "./raw/{ref}/{path}",
+		},
+		Capabilities: ManifestCapabilities{
+			Refs:         true,
+			Commits:      true,
+			Trees:        true,
+			BlobMetadata: false,
+			RawBlobs:     true,
+			Search:       false,
+			Archive:      false,
+		},
+		GeneratedAt: time.Now(),
+	}
+}
diff --git a/internal/render/render.go b/internal/render/render.go
new file mode 100644
index 0000000..da48675
--- /dev/null
+++ b/internal/render/render.go
@@ -0,0 +1,400 @@
+package render
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"html/template"
+	"io/fs"
+	"net/url"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"time"
+	"unicode/utf8"
+
+	"github.com/tgckpg/flatgit/internal/config"
+	"github.com/tgckpg/flatgit/internal/gitcmd"
+)
+
+const maxBlobHTMLBytes = 512 * 1024
+
+type Renderer struct {
+	Git        *gitcmd.Runner
+	PublicURL  string
+	MaxCommits int
+}
+
+type RefInfo struct {
+	Name   string `json:"name"`
+	Short  string `json:"short"`
+	Kind   string `json:"kind"`
+	Commit string `json:"commit"`
+}
+
+type CommitInfo struct {
+	Hash    string `json:"hash"`
+	Short   string `json:"short"`
+	Author  string `json:"author"`
+	Email   string `json:"email,omitempty"`
+	Date    string `json:"date"`
+	Subject string `json:"subject"`
+}
+
+type TreeEntry struct {
+	Mode string `json:"mode"`
+	Type string `json:"type"`
+	Hash string `json:"hash"`
+	Path string `json:"path"`
+	Size int64  `json:"size,omitempty"`
+}
+
+func New(git *gitcmd.Runner, publicURL string, maxCommits int) *Renderer {
+	if maxCommits <= 0 {
+		maxCommits = 500
+	}
+	return &Renderer{Git: git, PublicURL: publicURL, MaxCommits: maxCommits}
+}
+
+func (r *Renderer) RenderRepo(ctx context.Context, repo config.Repo) error {
+	if !isSafeOutput(repo.OutputDir) {
+		return fmt.Errorf("unsafe output dir %q", repo.OutputDir)
+	}
+
+	commit, err := r.resolveDefaultCommit(ctx, repo)
+	if err != nil {
+		return err
+	}
+	refs, err := r.refs(ctx, repo)
+	if err != nil {
+		return err
+	}
+	commits, err := r.commits(ctx, repo, commit)
+	if err != nil {
+		return err
+	}
+	tree, err := r.tree(ctx, repo, commit)
+	if err != nil {
+		return err
+	}
+
+	next := repo.OutputDir + ".next"
+	old := repo.OutputDir + ".old"
+	_ = os.RemoveAll(next)
+	_ = os.RemoveAll(old)
+	if err := os.MkdirAll(next, 0o755); err != nil {
+		return err
+	}
+
+	if err := writeStatic(next); err != nil {
+		return err
+	}
+
+	manifest := NewManifest(repo, repo.DefaultBranch, commit)
+	if err := writeJSON(filepath.Join(next, "manifest.json"), manifest); err != nil {
+		return err
+	}
+
+	if err := writeJSON(filepath.Join(next, "refs.json"), refs); err != nil {
+		return err
+	}
+	if err := writeJSON(filepath.Join(next, "commits.json"), commits); err != nil {
+		return err
+	}
+	if err := writeJSON(filepath.Join(next, "tree.json"), tree); err != nil {
+		return err
+	}
+
+	page := basePage{RepoManifest: manifest, GeneratedAt: manifest.GeneratedAt.Format(time.RFC3339)}
+	if err := renderTemplate(filepath.Join(next, "index.html"), indexTemplate, struct {
+		basePage
+		Refs    []RefInfo
+		Commits []CommitInfo
+		Files   []TreeEntry
+	}{page, refs, firstCommits(commits, 20), firstTree(tree, 50)}); err != nil {
+		return err
+	}
+	if err := renderTemplate(filepath.Join(next, "refs.html"), refsTemplate, struct {
+		basePage
+		Refs []RefInfo
+	}{page, refs}); err != nil {
+		return err
+	}
+	if err := renderTemplate(filepath.Join(next, "log.html"), logTemplate, struct {
+		basePage
+		Commits []CommitInfo
+	}{page, commits}); err != nil {
+		return err
+	}
+
+	branchSlug := refSlug(repo.DefaultBranch)
+	if err := renderTemplate(filepath.Join(next, "tree", branchSlug, "index.html"), treeTemplate, struct {
+		basePage
+		Ref   string
+		Files []TreeEntry
+	}{page, repo.DefaultBranch, tree}); err != nil {
+		return err
+	}
+
+	for _, c := range commits {
+		show, err := r.Git.Text(ctx, repo.MirrorDir, "show", "--date=iso-strict-local", "--stat", "--patch", "--find-renames", "--no-ext-diff", "--no-color", c.Hash)
+		if err != nil {
+			return err
+		}
+		if err := renderTemplate(filepath.Join(next, "commit", c.Hash+".html"), commitTemplate, struct {
+			basePage
+			Commit CommitInfo
+			Show   string
+		}{page, c, show}); err != nil {
+			return err
+		}
+		if err := writeJSON(filepath.Join(next, "commit", c.Hash+".json"), c); err != nil {
+			return err
+		}
+	}
+
+	for _, e := range tree {
+		if e.Type != "blob" {
+			continue
+		}
+		content, err := r.Git.Output(ctx, repo.MirrorDir, "show", "--date=iso-strict-local", commit+":"+e.Path)
+		if err != nil {
+			return err
+		}
+		rawPath := filepath.Join(next, "raw", branchSlug, filepath.FromSlash(e.Path))
+		if err := writeFile(rawPath, content, 0o644); err != nil {
+			return err
+		}
+
+		blobPath := filepath.Join(next, "blob", branchSlug, filepath.FromSlash(e.Path)+".html")
+		blob := blobView{Path: e.Path, Size: int64(len(content)), RawHref: relPath(filepath.Dir(blobPath), rawPath)}
+		if len(content) > maxBlobHTMLBytes || bytes.IndexByte(content, 0) >= 0 || !utf8.Valid(content) {
+			blob.Binary = true
+		} else {
+			blob.Text = string(content)
+		}
+		if err := renderTemplate(blobPath, blobTemplate, struct {
+			basePage
+			Blob blobView
+		}{page, blob}); err != nil {
+			return err
+		}
+	}
+
+	if err := publish(next, repo.OutputDir, old); err != nil {
+		return err
+	}
+	return nil
+}
+
+func (r *Renderer) resolveDefaultCommit(ctx context.Context, repo config.Repo) (string, error) {
+	candidates := []string{"refs/heads/" + repo.DefaultBranch, repo.DefaultBranch, "HEAD"}
+	var last error
+	for _, ref := range candidates {
+		out, err := r.Git.Text(ctx, repo.MirrorDir, "rev-parse", "--verify", ref)
+		if err == nil {
+			return strings.TrimSpace(out), nil
+		}
+		last = err
+	}
+	return "", last
+}
+
+func (r *Renderer) refs(ctx context.Context, repo config.Repo) ([]RefInfo, error) {
+	out, err := r.Git.Output(ctx, repo.MirrorDir, "for-each-ref", "--format=%(refname)%00%(objectname)", "refs/heads", "refs/tags")
+	if err != nil {
+		return nil, err
+	}
+	var refs []RefInfo
+	for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
+		if line == "" {
+			continue
+		}
+		parts := strings.Split(line, "\x00")
+		if len(parts) < 2 {
+			continue
+		}
+		name := parts[0]
+		kind := "ref"
+		short := name
+		if strings.HasPrefix(name, "refs/heads/") {
+			kind = "head"
+			short = strings.TrimPrefix(name, "refs/heads/")
+		} else if strings.HasPrefix(name, "refs/tags/") {
+			kind = "tag"
+			short = strings.TrimPrefix(name, "refs/tags/")
+		}
+		refs = append(refs, RefInfo{Name: name, Short: short, Kind: kind, Commit: parts[1]})
+	}
+	return refs, nil
+}
+
+func (r *Renderer) commits(ctx context.Context, repo config.Repo, ref string) ([]CommitInfo, error) {
+	limit := strconv.Itoa(r.MaxCommits)
+	format := "%H%x00%h%x00%an%x00%ae%x00%ad%x00%s"
+	out, err := r.Git.Output(ctx, repo.MirrorDir, "log", "--date=iso-strict-local", "-n", limit, "--pretty=format:"+format, ref, "--")
+	if err != nil {
+		return nil, err
+	}
+	var commits []CommitInfo
+	for _, line := range strings.Split(string(out), "\n") {
+		if line == "" {
+			continue
+		}
+		p := strings.SplitN(line, "\x00", 6)
+		if len(p) != 6 {
+			continue
+		}
+		commits = append(commits, CommitInfo{Hash: p[0], Short: p[1], Author: p[2], Email: p[3], Date: p[4], Subject: p[5]})
+	}
+	return commits, nil
+}
+
+func (r *Renderer) tree(ctx context.Context, repo config.Repo, ref string) ([]TreeEntry, error) {
+	out, err := r.Git.Output(ctx, repo.MirrorDir, "ls-tree", "-r", "-z", ref)
+	if err != nil {
+		return nil, err
+	}
+	var entries []TreeEntry
+	for _, item := range bytes.Split(out, []byte{0}) {
+		if len(item) == 0 {
+			continue
+		}
+		before, path, ok := bytes.Cut(item, []byte{'\t'})
+		if !ok {
+			continue
+		}
+		fields := strings.Fields(string(before))
+		if len(fields) != 3 {
+			continue
+		}
+		entry := TreeEntry{Mode: fields[0], Type: fields[1], Hash: fields[2], Path: string(path)}
+		if entry.Type == "blob" {
+			if sizeOut, err := r.Git.Text(ctx, repo.MirrorDir, "cat-file", "-s", entry.Hash); err == nil {
+				if n, err := strconv.ParseInt(strings.TrimSpace(sizeOut), 10, 64); err == nil {
+					entry.Size = n
+				}
+			}
+		}
+		entries = append(entries, entry)
+	}
+	return entries, nil
+}
+
+type basePage struct {
+	RepoManifest Manifest
+	GeneratedAt  string
+}
+
+type blobView struct {
+	Path    string
+	Size    int64
+	RawHref string
+	Text    string
+	Binary  bool
+}
+
+func writeStatic(root string) error {
+	css := `body{max-width:1100px;margin:2rem auto;padding:0 1rem;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;line-height:1.45}a{color:inherit}header{border-bottom:1px solid #ddd;margin-bottom:1rem}nav a{margin-right:1rem}.muted{color:#666}.mono,pre,code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}table{border-collapse:collapse;width:100%}td,th{border-bottom:1px solid #eee;padding:.35rem;text-align:left;vertical-align:top}pre{overflow:auto;background:#f6f6f6;padding:1rem;border:1px solid #eee}.pill{border:1px solid #ddd;border-radius:999px;padding:.1rem .45rem;font-size:.85em}`
+	return writeFile(filepath.Join(root, "style.css"), []byte(css), 0o644)
+}
+
+func renderTemplate(path, text string, data any) error {
+	t, err := template.New(filepath.Base(path)).Funcs(template.FuncMap{
+		"short": func(s string) string {
+			if len(s) > 12 {
+				return s[:12]
+			}
+			return s
+		},
+		"href": func(parts ...string) string {
+			joined := strings.Join(parts, "/")
+			return url.PathEscape(joined)
+		},
+		"blobHref": func(refSlug, p string) string {
+			return "blob/" + refSlug + "/" + strings.TrimPrefix(p, "/") + ".html"
+		},
+	}).Parse(layoutTemplate + text)
+	if err != nil {
+		return err
+	}
+	var b bytes.Buffer
+	if err := t.ExecuteTemplate(&b, "layout", data); err != nil {
+		return err
+	}
+	return writeFile(path, b.Bytes(), 0o644)
+}
+
+func writeJSON(path string, v any) error {
+	var b bytes.Buffer
+	enc := json.NewEncoder(&b)
+	enc.SetIndent("", "  ")
+	if err := enc.Encode(v); err != nil {
+		return err
+	}
+	return writeFile(path, b.Bytes(), 0o644)
+}
+
+func writeFile(path string, b []byte, mode fs.FileMode) error {
+	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+		return err
+	}
+	return os.WriteFile(path, b, mode)
+}
+
+func publish(next, current, old string) error {
+	_ = os.RemoveAll(old)
+	if _, err := os.Stat(current); err == nil {
+		if err := os.Rename(current, old); err != nil {
+			return err
+		}
+	}
+	if err := os.Rename(next, current); err != nil {
+		if _, statErr := os.Stat(old); statErr == nil {
+			_ = os.Rename(old, current)
+		}
+		return err
+	}
+	_ = os.RemoveAll(old)
+	return nil
+}
+
+func firstCommits(in []CommitInfo, n int) []CommitInfo {
+	if len(in) <= n {
+		return in
+	}
+	return in[:n]
+}
+
+func firstTree(in []TreeEntry, n int) []TreeEntry {
+	if len(in) <= n {
+		return in
+	}
+	return in[:n]
+}
+
+func refSlug(ref string) string {
+	ref = strings.TrimPrefix(ref, "refs/heads/")
+	ref = strings.TrimPrefix(ref, "refs/tags/")
+	ref = strings.ReplaceAll(ref, "/", "__")
+	ref = strings.ReplaceAll(ref, "..", "_")
+	if ref == "" {
+		return "default"
+	}
+	return ref
+}
+
+func relPath(fromDir, to string) string {
+	rel, err := filepath.Rel(fromDir, to)
+	if err != nil {
+		return to
+	}
+	return filepath.ToSlash(rel)
+}
+
+func isSafeOutput(path string) bool {
+	clean := filepath.Clean(path)
+	return clean != "." && clean != "/" && clean != ""
+}
diff --git a/internal/render/templates.go b/internal/render/templates.go
new file mode 100644
index 0000000..3a23b35
--- /dev/null
+++ b/internal/render/templates.go
@@ -0,0 +1,120 @@
+package render
+
+const layoutTemplate = `{{define "layout"}}<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{template "pageTitle" .}}</title>
+<link rel="stylesheet" href="{{.RepoManifest.Repository.SitePath}}style.css">
+<link rel="alternate" type="application/json" title="flatgit repository manifest" href="{{.RepoManifest.Repository.SitePath}}manifest.json">
+<link rel="alternate" type="application/json" title="flatgit refs" href="{{.RepoManifest.Repository.SitePath}}refs.json">
+<link rel="alternate" type="application/json" title="flatgit commits" href="{{.RepoManifest.Repository.SitePath}}commits.json">
+</head>
+<body>
+<header>
+<h1><a href="{{.RepoManifest.Repository.SitePath}}">{{.RepoManifest.Repository.FullName}}</a></h1>
+{{if .RepoManifest.Repository.Description}}<p class="muted">{{.RepoManifest.Repository.Description}}</p>{{end}}
+<nav>
+<a href="{{.RepoManifest.Repository.SitePath}}">summary</a>
+<a href="{{.RepoManifest.Repository.SitePath}}log.html">log</a>
+<a href="{{.RepoManifest.Repository.SitePath}}refs.html">refs</a>
+<a href="{{.RepoManifest.Repository.SitePath}}tree/{{.RepoManifest.Repository.DefaultRefSlug}}/index.html">files</a>
+<a href="{{.RepoManifest.Repository.SitePath}}manifest.json" title="express view for bots and agents">json</a>
+</nav>
+</header>
+<main>
+{{template "content" .}}
+</main>
+<footer>
+<p class="muted">generated by flatgit at {{.GeneratedAt}}</p>
+</footer>
+</body>
+</html>{{end}}
+`
+
+const indexTemplate = `{{define "pageTitle"}}{{.RepoManifest.Repository.FullName}} - flatgit{{end}}
+{{define "content"}}
+<h2>summary</h2>
+<table>
+<tr><th>default branch</th><td class="mono">{{.RepoManifest.Repository.DefaultBranch}}</td></tr>
+<tr><th>default commit</th><td class="mono">{{.RepoManifest.Repository.DefaultCommit}}</td></tr>
+</table>
+
+<h2>recent commits</h2>
+<table>
+<tr><th>commit</th><th>date</th><th>author</th><th>subject</th></tr>
+{{range .Commits}}
+<tr><td class="mono"><a href="{{$.RepoManifest.Repository.SitePath}}commit/{{.Hash}}.html">{{.Short}}</a></td><td>{{.Date}}</td><td>{{.Author}}</td><td>{{.Subject}}</td></tr>
+{{end}}
+</table>
+
+<h2>files</h2>
+<table>
+<tr><th>path</th><th>size</th></tr>
+{{range .Files}}
+<tr><td class="mono"><a href="{{$.RepoManifest.Repository.SitePath}}blob/{{$.RepoManifest.Repository.DefaultRefSlug}}/{{.Path}}.html">{{.Path}}</a></td><td>{{.Size}}</td></tr>
+{{end}}
+</table>
+{{end}}
+`
+
+const refsTemplate = `{{define "pageTitle"}}{{.RepoManifest.Repository.FullName}} refs{{end}}
+{{define "content"}}
+<h2>refs</h2>
+<table>
+<tr><th>kind</th><th>name</th><th>commit</th></tr>
+{{range .Refs}}
+<tr><td><span class="pill">{{.Kind}}</span></td><td class="mono">{{.Short}}</td><td class="mono">{{.Commit}}</td></tr>
+{{end}}
+</table>
+{{end}}
+`
+
+const logTemplate = `{{define "pageTitle"}}{{.RepoManifest.Repository.FullName}} log{{end}}
+{{define "content"}}
+<h2>commit log</h2>
+<table>
+<tr><th>commit</th><th>date</th><th>author</th><th>subject</th></tr>
+{{range .Commits}}
+<tr><td class="mono"><a href="{{$.RepoManifest.Repository.SitePath}}commit/{{.Hash}}.html">{{.Short}}</a></td><td>{{.Date}}</td><td>{{.Author}}</td><td>{{.Subject}}</td></tr>
+{{end}}
+</table>
+{{end}}
+`
+
+const treeTemplate = `{{define "pageTitle"}}{{.RepoManifest.Repository.FullName}} files{{end}}
+{{define "content"}}
+<h2>files: {{.Ref}}</h2>
+<table>
+<tr><th>mode</th><th>type</th><th>path</th><th>size</th></tr>
+{{range .Files}}
+<tr><td class="mono">{{.Mode}}</td><td>{{.Type}}</td><td class="mono"><a href="{{$.RepoManifest.Repository.SitePath}}blob/{{$.RepoManifest.Repository.DefaultRefSlug}}/{{.Path}}.html">{{.Path}}</a></td><td>{{.Size}}</td></tr>
+{{end}}
+</table>
+{{end}}
+`
+
+const commitTemplate = `{{define "pageTitle"}}{{.RepoManifest.Repository.FullName}} commit {{.Commit.Short}}{{end}}
+{{define "content"}}
+<h2>commit <span class="mono">{{.Commit.Hash}}</span></h2>
+<table>
+<tr><th>author</th><td>{{.Commit.Author}} &lt;{{.Commit.Email}}&gt;</td></tr>
+<tr><th>date</th><td>{{.Commit.Date}}</td></tr>
+<tr><th>subject</th><td>{{.Commit.Subject}}</td></tr>
+</table>
+<pre>{{.Show}}</pre>
+{{end}}
+`
+
+const blobTemplate = `{{define "pageTitle"}}{{.RepoManifest.Repository.FullName}} {{.Blob.Path}}{{end}}
+{{define "content"}}
+<h2 class="mono">{{.Blob.Path}}</h2>
+<p><a href="{{.Blob.RawHref}}">raw</a> · {{.Blob.Size}} bytes</p>
+{{if .Blob.Binary}}
+<p>Binary or large file; HTML preview skipped.</p>
+{{else}}
+<pre>{{.Blob.Text}}</pre>
+{{end}}
+{{end}}
+`
diff --git a/internal/server/server.go b/internal/server/server.go
new file mode 100644
index 0000000..16f9692
--- /dev/null
+++ b/internal/server/server.go
@@ -0,0 +1,100 @@
+package server
+
+import (
+	"context"
+	"errors"
+	"log/slog"
+	"net/http"
+	"path"
+	"strings"
+	"time"
+)
+
+type Options struct {
+	Addr       string
+	Root       string
+	Logger     *slog.Logger
+	WebhookMux func(*http.ServeMux)
+}
+
+func withCacheHeaders(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		ext := path.Ext(r.URL.Path)
+
+		switch ext {
+		case ".html", ".json", ".xml", ".txt":
+			w.Header().Set("Cache-Control", "no-cache")
+		case ".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".woff", ".woff2":
+			w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+		default:
+			// Raw files are tricky.
+			// For now, keep them revalidating unless we make commit-addressed raw URLs.
+			w.Header().Set("Cache-Control", "no-cache")
+		}
+
+		next.ServeHTTP(w, r)
+	})
+}
+
+func ListenAndServe(ctx context.Context, opts Options) error {
+	if opts.Addr == "" {
+		opts.Addr = ":8080"
+	}
+	if opts.Logger == nil {
+		opts.Logger = slog.Default()
+	}
+
+	mux := http.NewServeMux()
+	mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
+		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+		_, _ = w.Write([]byte("ok\n"))
+	})
+	if opts.WebhookMux != nil {
+		opts.WebhookMux(mux)
+	}
+	fs := http.FileServer(http.Dir(opts.Root))
+	mux.Handle("/", withCacheHeaders(fs))
+
+	srv := &http.Server{
+		Addr:              opts.Addr,
+		Handler:           logRequests(opts.Logger, mux),
+		ReadHeaderTimeout: 5 * time.Second,
+	}
+
+	errCh := make(chan error, 1)
+	opts.Logger.Info("flatgit serving", "addr", opts.Addr, "root", opts.Root)
+
+	go func() {
+		errCh <- srv.ListenAndServe()
+	}()
+
+	select {
+	case err := <-errCh:
+		if errors.Is(err, http.ErrServerClosed) {
+			return nil
+		}
+		return err
+	case <-ctx.Done():
+		opts.Logger.Info("flatgit shutting down")
+
+		shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+		defer cancel()
+
+		if err := srv.Shutdown(shutdownCtx); err != nil {
+			return err
+		}
+		return nil
+	}
+}
+
+func logRequests(log *slog.Logger, next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if strings.HasPrefix(r.URL.Path, "/healthz") {
+			next.ServeHTTP(w, r)
+			return
+		}
+		start := time.Now()
+		next.ServeHTTP(w, r)
+		log.Info("http request", "method", r.Method, "path", r.URL.Path, "remote", r.RemoteAddr, "dur", time.Since(start))
+	})
+}
diff --git a/internal/webhook/webhook.go b/internal/webhook/webhook.go
new file mode 100644
index 0000000..1d73e40
--- /dev/null
+++ b/internal/webhook/webhook.go
@@ -0,0 +1,115 @@
+package webhook
+
+import (
+	"crypto/hmac"
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"io"
+	"log/slog"
+	"net/http"
+	"strings"
+
+	"github.com/tgckpg/flatgit/internal/config"
+)
+
+type Enqueuer interface {
+	Enqueue(string) bool
+}
+
+type Handler struct {
+	Config  *config.Config
+	Queue   Enqueuer
+	Logger  *slog.Logger
+	MaxBody int64
+}
+
+type payload struct {
+	Ref        string `json:"ref"`
+	Repository struct {
+		Name     string `json:"name"`
+		FullName string `json:"full_name"`
+		CloneURL string `json:"clone_url"`
+		HTMLURL  string `json:"html_url"`
+	} `json:"repository"`
+}
+
+func (h *Handler) Register(mux *http.ServeMux) {
+	mux.HandleFunc("/webhook/gitea", h.ServeHTTP)
+	mux.HandleFunc("/webhook/github", h.ServeHTTP)
+}
+
+func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodPost {
+		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+	if h.MaxBody <= 0 {
+		h.MaxBody = 1 << 20
+	}
+	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, h.MaxBody))
+	if err != nil {
+		http.Error(w, "bad body", http.StatusBadRequest)
+		return
+	}
+	if !verifySignature(h.Config.Webhook.Secret, body, r) {
+		http.Error(w, "bad signature", http.StatusUnauthorized)
+		return
+	}
+
+	var p payload
+	if err := json.Unmarshal(body, &p); err != nil {
+		http.Error(w, "bad json", http.StatusBadRequest)
+		return
+	}
+	fullName := firstNonEmpty(p.Repository.FullName, p.Repository.Name)
+	if fullName == "" {
+		http.Error(w, "missing repository name", http.StatusBadRequest)
+		return
+	}
+	repo, ok := h.Config.RepoByWebhookName(fullName)
+	if !ok {
+		http.Error(w, fmt.Sprintf("repo not configured: %s", fullName), http.StatusNotFound)
+		return
+	}
+	queued := h.Queue.Enqueue(repo.Name)
+	if h.Logger != nil {
+		h.Logger.Info("webhook received", "repo", repo.FullName(), "ref", p.Ref, "queued", queued)
+	}
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(http.StatusAccepted)
+	_, _ = fmt.Fprintf(w, `{"ok":true,"queued":%t,"repo":%q}`+"\n", queued, repo.FullName())
+}
+
+func verifySignature(secret string, body []byte, r *http.Request) bool {
+	if secret == "" {
+		return true
+	}
+	mac := hmac.New(sha256.New, []byte(secret))
+	_, _ = mac.Write(body)
+	expected := mac.Sum(nil)
+
+	github := r.Header.Get("X-Hub-Signature-256")
+	if strings.HasPrefix(github, "sha256=") {
+		got, err := hex.DecodeString(strings.TrimPrefix(github, "sha256="))
+		return err == nil && hmac.Equal(got, expected)
+	}
+
+	gitea := r.Header.Get("X-Gitea-Signature")
+	if gitea != "" {
+		got, err := hex.DecodeString(gitea)
+		return err == nil && hmac.Equal(got, expected)
+	}
+
+	return false
+}
+
+func firstNonEmpty(v ...string) string {
+	for _, s := range v {
+		if strings.TrimSpace(s) != "" {
+			return strings.TrimSpace(s)
+		}
+	}
+	return ""
+}
diff --git a/resources/robots.txt b/resources/robots.txt
new file mode 100644
index 0000000..2ae303a
--- /dev/null
+++ b/resources/robots.txt
@@ -0,0 +1,5 @@
+User-agent: *
+Allow: /
+
+Sitemap: /sitemap.xml
+Flatgit: /.well-known/flatgit.json