josie / simplegit

simplegit: self-hosted git server (1.0)

Single-user, HTTP-only, server-rendered git host: smart-HTTP git
over HTTPS, issues and pull requests (with owner-approved guest
filing), releases, git tokens, and a dark Courier UI. One static
Go binary, SQLite, reverse-proxy TLS. See docs/self-host.md.

LLM Contributor: qwen/qwen3.8-27b

b93e14ae88f05c6e01d25ab41d2ecb7456d769b3
cjosie <administrator@josie-c.com> · 2026-10-05T12:01 · browse files at this commit

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0a7a64b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,15 @@
+# runtime state: bare repos, sqlite db, uploads
+/data/
+
+# local config (see simplegit.toml.example)
+/simplegit.toml
+
+# built binary
+/simplegit
+
+# local UAT scratch (scripts/local-uat.sh)
+/.uat/
+.projectmemory-browse/
+
+# internal LLM work files (memory, to-dos, handoffs)
+/.LLM_Memory/
diff --git a/cmd/simplegit/main.go b/cmd/simplegit/main.go
new file mode 100644
index 0000000..c9179e5
--- /dev/null
+++ b/cmd/simplegit/main.go
@@ -0,0 +1,271 @@
+// Command simplegit is a self-hosted git host: one static binary serving
+// smart-HTTP git and a server-rendered web UI.
+package main
+
+import (
+	"bufio"
+	"database/sql"
+	"errors"
+	"flag"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+	"time"
+
+	"git.josie-c.com/josie/simplegit/internal/auth"
+	"git.josie-c.com/josie/simplegit/internal/config"
+	"git.josie-c.com/josie/simplegit/internal/db"
+	"git.josie-c.com/josie/simplegit/internal/git"
+	"git.josie-c.com/josie/simplegit/internal/web"
+	"golang.org/x/term"
+)
+
+func main() {
+	if len(os.Args) < 2 {
+		usage()
+		os.Exit(2)
+	}
+
+	var err error
+	switch os.Args[1] {
+	case "serve":
+		err = runServe(os.Args[2:])
+	case "adduser":
+		err = runAddUser(os.Args[2:])
+	case "hook":
+		err = runHook(os.Args[2:])
+	case "-h", "--help", "help":
+		usage()
+		return
+	default:
+		fmt.Fprintf(os.Stderr, "simplegit: unknown command %q\n", os.Args[1])
+		usage()
+		os.Exit(2)
+	}
+
+	if err != nil {
+		fmt.Fprintln(os.Stderr, "simplegit:", err)
+		os.Exit(1)
+	}
+}
+
+func usage() {
+	fmt.Fprint(os.Stderr, `usage: simplegit <command> [flags]
+
+commands:
+  serve     run the HTTP server
+  adduser   create the account
+  hook      post-receive callback (invoked by git, not by hand)
+`)
+}
+
+func runServe(args []string) error {
+	fs := flag.NewFlagSet("serve", flag.ExitOnError)
+	configPath := fs.String("config", "simplegit.toml", "path to TOML config file")
+	if err := fs.Parse(args); err != nil {
+		return err
+	}
+
+	cfg, database, err := openStore(*configPath)
+	if err != nil {
+		return err
+	}
+	defer database.Close()
+
+	server, err := web.New(database, cfg)
+	if err != nil {
+		return err
+	}
+
+	fmt.Printf("simplegit: listening on %s (base URL %s)\n", cfg.ListenAddr, cfg.BaseURL)
+	return server.Listen()
+}
+
+// usernames become URL path segments (/user/repo), so keep them boring.
+var usernamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]*$`)
+
+func runAddUser(args []string) error {
+	fs := flag.NewFlagSet("adduser", flag.ExitOnError)
+	configPath := fs.String("config", "simplegit.toml", "path to TOML config file")
+	if err := fs.Parse(args); err != nil {
+		return err
+	}
+	if fs.NArg() != 1 {
+		return fmt.Errorf("usage: simplegit adduser <username>")
+	}
+	username := fs.Arg(0)
+	if !usernamePattern.MatchString(username) {
+		return fmt.Errorf("username %q must be letters, digits, dot, dash or underscore", username)
+	}
+
+	_, database, err := openStore(*configPath)
+	if err != nil {
+		return err
+	}
+	defer database.Close()
+
+	_, err = db.GetUserByName(database, username)
+	if err == nil {
+		return fmt.Errorf("user %q already exists", username)
+	}
+	if !errors.Is(err, db.ErrNotFound) {
+		return err
+	}
+
+	password, err := readPassword()
+	if err != nil {
+		return err
+	}
+	hash, err := auth.HashPassword(password)
+	if err != nil {
+		return err
+	}
+	id, err := db.CreateUser(database, username, hash)
+	if err != nil {
+		return err
+	}
+	fmt.Printf("simplegit: created user %q (id %d)\n", username, id)
+	return nil
+}
+
+// readPassword takes the password from stdin when it is piped — so scripts
+// can do `printf '%s\n' "$pw" | simplegit adduser josie` without exposing
+// it in the process list — and falls back to the hidden terminal prompt.
+func readPassword() (string, error) {
+	if term.IsTerminal(int(os.Stdin.Fd())) {
+		return promptPassword()
+	}
+	line, err := io.ReadAll(os.Stdin)
+	if err != nil {
+		return "", fmt.Errorf("read password: %w", err)
+	}
+	password := strings.TrimRight(string(line), "\r\n")
+	if password == "" {
+		return "", errors.New("password must not be empty")
+	}
+	return password, nil
+}
+
+func promptPassword() (string, error) {
+	fmt.Print("password: ")
+	first, err := term.ReadPassword(int(os.Stdin.Fd()))
+	fmt.Println()
+	if err != nil {
+		return "", fmt.Errorf("read password: %w", err)
+	}
+	fmt.Print("password (again): ")
+	second, err := term.ReadPassword(int(os.Stdin.Fd()))
+	fmt.Println()
+	if err != nil {
+		return "", fmt.Errorf("read password: %w", err)
+	}
+	if string(first) != string(second) {
+		return "", errors.New("passwords do not match")
+	}
+	if len(first) == 0 {
+		return "", errors.New("password must not be empty")
+	}
+	return string(first), nil
+}
+
+func runHook(args []string) error {
+	fs := flag.NewFlagSet("hook", flag.ExitOnError)
+	repoPath := fs.String("repo", "", "path to the bare repository (set by the installed hook)")
+	if err := fs.Parse(args); err != nil {
+		return err
+	}
+	if *repoPath == "" {
+		return errors.New("hook: --repo is required")
+	}
+	owner, name, dataDir, err := splitRepoPath(*repoPath)
+	if err != nil {
+		return err
+	}
+
+	database, err := db.Open(filepath.Join(dataDir, "simplegit.db"))
+	if err != nil {
+		return err
+	}
+	defer database.Close()
+
+	branches, err := pushedBranches(os.Stdin)
+	if err != nil {
+		return err
+	}
+
+	// Establish a default branch when HEAD points at a branch that does not
+	// exist and this push created one (e.g. a first push of "master").
+	defaultBranch := ""
+	if current, err := git.DefaultBranch(*repoPath); err == nil {
+		if exists, _ := git.RefExists(*repoPath, current); !exists && len(branches) > 0 {
+			defaultBranch = branches[0]
+			if err := git.SetDefaultBranch(*repoPath, defaultBranch); err != nil {
+				return err
+			}
+		}
+	}
+	return db.RecordPush(database, owner, name, time.Now().Unix(), defaultBranch)
+}
+
+// splitRepoPath maps <dataDir>/repos/<owner>/<name>.git back to its parts.
+func splitRepoPath(repoPath string) (owner, name, dataDir string, err error) {
+	abs, err := filepath.Abs(repoPath)
+	if err != nil {
+		return "", "", "", fmt.Errorf("resolve repo path: %w", err)
+	}
+	if !strings.HasSuffix(abs, ".git") {
+		return "", "", "", fmt.Errorf("hook: repo path %q is not a .git directory", repoPath)
+	}
+	name = strings.TrimSuffix(filepath.Base(abs), ".git")
+	owner = filepath.Base(filepath.Dir(abs))
+	dataDir = filepath.Dir(filepath.Dir(filepath.Dir(abs)))
+	return owner, name, dataDir, nil
+}
+
+// pushedBranches returns the branch names in post-receive stdin updates.
+func pushedBranches(r io.Reader) ([]string, error) {
+	var branches []string
+	scanner := bufio.NewScanner(r)
+	for scanner.Scan() {
+		fields := strings.Fields(scanner.Text())
+		if len(fields) < 3 {
+			continue
+		}
+		if branch, ok := strings.CutPrefix(fields[2], "refs/heads/"); ok {
+			branches = append(branches, branch)
+		}
+	}
+	return branches, scanner.Err()
+}
+
+// openStore loads the config, creates the runtime directory layout, and
+// opens the metadata database — the setup shared by serve and adduser.
+func openStore(configPath string) (config.Config, *sql.DB, error) {
+	cfg, err := config.Load(configPath)
+	if err != nil {
+		return cfg, nil, err
+	}
+	if err := ensureDataDir(cfg.DataDir); err != nil {
+		return cfg, nil, err
+	}
+	database, err := db.Open(filepath.Join(cfg.DataDir, "simplegit.db"))
+	if err != nil {
+		return cfg, nil, err
+	}
+	return cfg, database, nil
+}
+
+// ensureDataDir creates the runtime directory layout, idempotently. 0700
+// keeps session tokens, password hashes, and private repo objects away
+// from other local accounts.
+func ensureDataDir(dir string) error {
+	for _, sub := range []string{"", "repos", "uploads"} {
+		if err := os.MkdirAll(filepath.Join(dir, sub), 0o700); err != nil {
+			return fmt.Errorf("create data dir: %w", err)
+		}
+	}
+	return nil
+}
diff --git a/cmd/simplegit/main_test.go b/cmd/simplegit/main_test.go
new file mode 100644
index 0000000..6932934
--- /dev/null
+++ b/cmd/simplegit/main_test.go
@@ -0,0 +1,159 @@
+package main
+
+import (
+	"database/sql"
+	"io"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"git.josie-c.com/josie/simplegit/internal/db"
+	"git.josie-c.com/josie/simplegit/internal/git"
+)
+
+func runGit(t *testing.T, dir string, args ...string) {
+	t.Helper()
+	cmd := exec.Command("git", args...)
+	cmd.Dir = dir
+	if out, err := cmd.CombinedOutput(); err != nil {
+		t.Fatalf("git %v: %v: %s", args, err, out)
+	}
+}
+
+func hashObject(t *testing.T, repoPath, content string) string {
+	t.Helper()
+	cmd := exec.Command("git", "hash-object", "-w", "--stdin")
+	cmd.Dir = repoPath
+	cmd.Stdin = strings.NewReader(content)
+	out, err := cmd.Output()
+	if err != nil {
+		t.Fatalf("hash-object: %v", err)
+	}
+	return strings.TrimSpace(string(out))
+}
+
+// commitToBranch writes a one-file commit and points branch at it.
+func commitToBranch(t *testing.T, repoPath, branch string) string {
+	t.Helper()
+	blob := hashObject(t, repoPath, "hello\n")
+	treeCmd := exec.Command("git", "mktree")
+	treeCmd.Dir = repoPath
+	treeCmd.Stdin = strings.NewReader("100644 blob " + blob + "\tfile\n")
+	treeOut, err := treeCmd.Output()
+	if err != nil {
+		t.Fatalf("mktree: %v", err)
+	}
+	commitCmd := exec.Command("git", "commit-tree", strings.TrimSpace(string(treeOut)), "-m", "seed")
+	commitCmd.Dir = repoPath
+	commitCmd.Env = append(os.Environ(),
+		"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
+		"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
+	commitOut, err := commitCmd.Output()
+	if err != nil {
+		t.Fatalf("commit-tree: %v", err)
+	}
+	sha := strings.TrimSpace(string(commitOut))
+	runGit(t, repoPath, "update-ref", "refs/heads/"+branch, sha)
+	return sha
+}
+
+func newHookFixture(t *testing.T) (string, *sql.DB) {
+	t.Helper()
+	dataDir := t.TempDir()
+	repoPath := filepath.Join(dataDir, "repos", "josie", "demo.git")
+	if err := os.MkdirAll(filepath.Dir(repoPath), 0o755); err != nil {
+		t.Fatalf("mkdir: %v", err)
+	}
+	if err := git.InitBare(repoPath, "main"); err != nil {
+		t.Fatalf("InitBare: %v", err)
+	}
+	database, err := db.Open(filepath.Join(dataDir, "simplegit.db"))
+	if err != nil {
+		t.Fatalf("db.Open: %v", err)
+	}
+	t.Cleanup(func() { database.Close() })
+
+	ownerID, err := db.CreateUser(database, "josie", "hash")
+	if err != nil {
+		t.Fatalf("CreateUser: %v", err)
+	}
+	if _, err := db.CreateRepo(database, ownerID, "demo", "", "private"); err != nil {
+		t.Fatalf("CreateRepo: %v", err)
+	}
+	return repoPath, database
+}
+
+func runHookWithStdin(t *testing.T, repoPath, stdin string) error {
+	t.Helper()
+	r, w, err := os.Pipe()
+	if err != nil {
+		t.Fatalf("pipe: %v", err)
+	}
+	old := os.Stdin
+	os.Stdin = r
+	defer func() {
+		os.Stdin = old
+		r.Close()
+	}()
+	go func() {
+		_, _ = io.WriteString(w, stdin)
+		w.Close()
+	}()
+	return runHook([]string{"--repo", repoPath})
+}
+
+func TestRunHookEstablishesDefaultBranch(t *testing.T) {
+	repoPath, database := newHookFixture(t)
+	sha := commitToBranch(t, repoPath, "master")
+
+	stdin := "0000000000000000000000000000000000000000 " + sha + " refs/heads/master\n"
+	if err := runHookWithStdin(t, repoPath, stdin); err != nil {
+		t.Fatalf("runHook: %v", err)
+	}
+
+	repo, err := db.GetRepoByName(database, "josie", "demo")
+	if err != nil {
+		t.Fatalf("GetRepoByName: %v", err)
+	}
+	if repo.DefaultBranch != "master" {
+		t.Errorf("DefaultBranch = %q, want master", repo.DefaultBranch)
+	}
+	if !repo.PushedAt.Valid {
+		t.Error("PushedAt is NULL, want a timestamp")
+	}
+	if branch, err := git.DefaultBranch(repoPath); err != nil || branch != "master" {
+		t.Errorf("HEAD branch = %q, %v; want master", branch, err)
+	}
+}
+
+func TestRunHookKeepsExistingDefaultBranch(t *testing.T) {
+	repoPath, database := newHookFixture(t)
+	sha := commitToBranch(t, repoPath, "main")
+
+	stdin := "0000000000000000000000000000000000000000 " + sha + " refs/heads/main\n"
+	if err := runHookWithStdin(t, repoPath, stdin); err != nil {
+		t.Fatalf("runHook: %v", err)
+	}
+	repo, err := db.GetRepoByName(database, "josie", "demo")
+	if err != nil {
+		t.Fatalf("GetRepoByName: %v", err)
+	}
+	if repo.DefaultBranch != "main" {
+		t.Errorf("DefaultBranch = %q, want unchanged main", repo.DefaultBranch)
+	}
+}
+
+func TestSplitRepoPath(t *testing.T) {
+	owner, name, dataDir, err := splitRepoPath("/srv/simplegit/data/repos/josie/demo.git")
+	if err != nil {
+		t.Fatalf("splitRepoPath: %v", err)
+	}
+	if owner != "josie" || name != "demo" || dataDir != "/srv/simplegit/data" {
+		t.Errorf("split = %q/%q in %q", owner, name, dataDir)
+	}
+	if _, _, _, err := splitRepoPath("/tmp/not-a-repo"); err == nil {
+		t.Error("splitRepoPath accepted a non-.git path")
+	}
+}
diff --git a/deploy/Caddyfile.simplegit.example b/deploy/Caddyfile.simplegit.example
new file mode 100644
index 0000000..5e88b9f
--- /dev/null
+++ b/deploy/Caddyfile.simplegit.example
@@ -0,0 +1,5 @@
+# simplegit site (Caddy, automatic HTTPS)
+# Installed by scripts/install.sh with the domain and loopback substituted.
+{{DOMAIN}} {
+    reverse_proxy {{LOOPBACK}}:8080
+}
diff --git a/deploy/apache-simplegit.conf.example b/deploy/apache-simplegit.conf.example
new file mode 100644
index 0000000..0b4f936
--- /dev/null
+++ b/deploy/apache-simplegit.conf.example
@@ -0,0 +1,37 @@
+# simplegit reverse proxy (Apache httpd + Let's Encrypt)
+# Installed by scripts/install.sh with the domain substituted.
+# Edit ServerName here if the derived domain is wrong.
+
+<VirtualHost *:80>
+    ServerName {{DOMAIN}}
+    DocumentRoot /var/www/html
+    <Location "/.well-known/acme-challenge/">
+        Require all granted
+    </Location>
+    RewriteEngine On
+    RewriteCond %{REQUEST_URI} !^/\.well-known/acme-challenge/
+    RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [R=301,L]
+</VirtualHost>
+
+<VirtualHost *:443>
+    ServerName {{DOMAIN}}
+    SSLEngine on
+    SSLCertificateFile      /etc/letsencrypt/live/{{DOMAIN}}/fullchain.pem
+    SSLCertificateKeyFile   /etc/letsencrypt/live/{{DOMAIN}}/privkey.pem
+
+    ProxyPreserveHost On
+    ProxyRequests Off
+    ProxyTimeout 600s
+    Timeout      600
+    AllowEncodedSlashes NoDecode
+
+    SetEnv no-gzip 1
+    SetEnv dont-vary 1
+    LimitRequestBody 0
+
+    ProxyPass        / http://{{LOOPBACK}}:8080/ retry=0
+    ProxyPassReverse / http://{{LOOPBACK}}:8080/
+
+    ErrorLog  ${APACHE_LOG_DIR}/simplegit-error.log
+    CustomLog ${APACHE_LOG_DIR}/simplegit-access.log combined
+</VirtualHost>
diff --git a/docs/self-host.md b/docs/self-host.md
new file mode 100644
index 0000000..fc682e0
--- /dev/null
+++ b/docs/self-host.md
@@ -0,0 +1,259 @@
+# Self-hosting simplegit
+
+simplegit is one static binary: it serves smart-HTTP git and a
+server-rendered web UI. TLS is terminated by a reverse proxy; the binary
+listens on plain HTTP and requires the `git` binary on `PATH` at runtime.
+
+# 1. Install (recommended)
+
+`scripts/install.sh` does steps 1-5 for you (binary, system user, data dir,
+config, proxy example conf, systemd unit):
+
+```sh
+sudo scripts/install.sh https://git.example.com apache
+# or: ... https://git.example.com caddy | none
+```
+
+The second argument picks the reverse-proxy example conf, with the domain
+substituted: `apache` installs `/etc/apache2/sites-available/simplegit.conf`
+(and enables modules/site), `caddy` installs the site into the Caddyfile
+(or a `Caddyfile.simplegit` to `import`), `none` skips it. The unit is
+enabled but not started; the config is written only if absent.
+
+Manual equivalent, if you prefer to do it by hand:
+
+### 1a. Build
+
+```sh
+go build -o /usr/local/bin/simplegit ./cmd/simplegit
+```
+
+The binary embeds templates and static assets, so there is nothing else to
+copy. It needs `git` (with `http-backend`) installed on the host.
+
+## 2. Config
+
+Create `simplegit.toml` (see `simplegit.toml.example`). Every key is
+optional.
+
+```toml
+data_dir    = "/var/lib/simplegit"       # bare repos, SQLite DB, uploads
+listen_addr = "127.0.0.1:8080"           # plain HTTP; the proxy fronts it
+base_url    = "https://git.example.com"  # public URL; builds clone URLs/links
+```
+
+`base_url` must be the public HTTPS URL, or clone URLs and `Secure` session
+cookies will be wrong.
+
+## 3. Account and data dir
+
+```sh
+simplegit adduser -config simplegit.toml <username>
+```
+
+Flags come **before** the positional username (Go's flag parsing stops at
+the first non-flag). In a terminal you get a hidden password prompt (asked
+twice); for scripting, pipe the password on stdin so it never appears in
+the process list:
+
+```sh
+printf '%s\n' "$PASSWORD" | simplegit adduser -config simplegit.toml <username>
+```
+
+`serve` and `adduser` create the data-dir layout on first run, mode 0700.
+If the directory already exists from an older install, tighten it once:
+`chmod -R go-rwx /var/lib/simplegit`. Setting `UMask=0077` in the systemd
+unit keeps everything the service creates private as well.
+
+## 4. Run it
+
+```sh
+simplegit serve -config /etc/simplegit/simplegit.toml
+```
+
+### systemd unit
+
+```ini
+[Unit]
+Description=simplegit
+After=network.target
+
+[Service]
+ExecStart=/usr/local/bin/simplegit serve -config /etc/simplegit/simplegit.toml
+Restart=on-failure
+User=simplegit
+Group=simplegit
+
+[Install]
+WantedBy=multi-user.target
+```
+
+Run it as a dedicated user that owns `data_dir`. It does not need root and
+binds only the loopback address the proxy forwards to.
+
+## 5. Reverse proxy (TLS)
+
+Caddy terminates TLS automatically:
+
+```
+git.example.com {
+    reverse_proxy 127.0.0.1:8080
+}
+```
+
+Two proxy requirements:
+
+- **Do not rewrite or buffer git request/response bodies.** Pushes stream a
+  packfile both ways.
+- **Do not pass `X-Forwarded-For` expectations to the app** — the guest-write
+  rate limiter keys on the socket `RemoteAddr`, so behind a proxy all clients
+  share one bucket. (Trusting the forwarded header is a deliberate future
+  decision, not current behavior.)
+
+nginx: `proxy_pass http://127.0.0.1:8080;` with `proxy_request_buffering off;`
+and a generous `client_max_body_size` (pushes and release uploads can be
+large).
+
+A request body that reaches the app while a response is being written is
+handled safely (the CGI stdin is drained before the response), but proxies
+that buffer or retry bodies are still discouraged.
+
+### Apache httpd + certbot
+
+Apache streams request bodies by default, so large pushes work once the
+timeouts are raised. On Debian/Ubuntu:
+
+```sh
+apt install apache2 certbot python3-certbot-apache
+a2enmod ssl proxy proxy_http headers rewrite
+```
+
+Point the DNS record at the host, then use a hand-owned vhost so certbot
+never rewrites your proxy config — `/etc/apache2/sites-available/simplegit.conf`:
+
+```apache
+<VirtualHost *:80>
+    ServerName git.example.com
+    DocumentRoot /var/www/html
+    <Location "/.well-known/acme-challenge/">
+        Require all granted
+    </Location>
+    RewriteEngine On
+    RewriteCond %{REQUEST_URI} !^/\.well-known/acme-challenge/
+    RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [R=301,L]
+</VirtualHost>
+
+<VirtualHost *:443>
+    ServerName git.example.com
+    SSLEngine on
+    SSLCertificateFile      /etc/letsencrypt/live/git.example.com/fullchain.pem
+    SSLCertificateKeyFile   /etc/letsencrypt/live/git.example.com/privkey.pem
+
+    ProxyPreserveHost On
+    ProxyRequests Off
+    # big pushes / release uploads
+    ProxyTimeout 600s
+    Timeout      600
+    # pass %2f through instead of 404ing
+    AllowEncodedSlashes NoDecode
+
+    # never re-encode the git stream
+    SetEnv no-gzip 1
+    SetEnv dont-vary 1
+    # unlimited (the default, but explicit)
+    LimitRequestBody 0
+
+    ProxyPass        / http://[IP_ADDRESS]:8080/ retry=0
+    ProxyPassReverse / http://[IP_ADDRESS]:8080/
+
+    ErrorLog  ${APACHE_LOG_DIR}/simplegit-error.log
+    CustomLog ${APACHE_LOG_DIR}/simplegit-access.log combined
+</VirtualHost>
+```
+
+```sh
+a2ensite simplegit && apachectl configtest && systemctl reload apache2
+certbot certonly --webroot -w /var/www/html -d git.example.com
+```
+
+(`certbot --apache -d git.example.com` also works, but writes its own
+`-le-ssl.conf` that you would then have to add the proxy block to; the
+`certonly` route keeps the file yours.) Renewal is automatic via the systemd
+timer — verify with `certbot renew --dry-run`.
+
+Apache-specific gotchas, all load-bearing:
+
+- **`ProxyPreserveHost On`** is mandatory, or Apache sends `Host: [IP_ADDRESS]`
+  and clone URLs/redirects come out wrong.
+- **`SetEnv no-gzip 1`** — `mod_deflate` re-encoding the streaming git
+  response breaks some clients.
+- **`ProxyTimeout` / `Timeout`** default to 60s and kill big pushes.
+- **`AllowEncodedSlashes NoDecode`** — Apache's default 404s any URL with `%2f`.
+- **Keep comments on their own lines.** On a 2.4.66/Ubuntu build, inline
+  `# …` comments on `ProxyTimeout`/`AllowEncodedSlashes` lines made
+  `apachectl configtest` fail with "takes one argument" (the comment text
+  parsed as a second argument); the same vhost passed once the comments
+  moved to their own lines. The example above is comment-free on
+  directive lines for that reason.
+- **The rate limiter sees `[IP_ADDRESS]`.** `mod_proxy` sets `X-Forwarded-For`
+  automatically, but the app deliberately doesn't trust it (see above).
+- Firewall only 80/443; keep the app on loopback.
+
+## 6. Dogfood (host simplegit on itself)
+
+```sh
+# create the repo in the web UI, then:
+git remote add origin https://git.example.com/<user>/simplegit.git
+git push -u origin main
+git tag v1.0.0 && git push origin v1.0.0
+```
+
+Then on the repo page: file an issue, open a pull request from a branch, and
+publish a release (Releases → new release, pick the tag, attach a binary).
+
+## 7. Local UAT (single machine, no proxy)
+
+To exercise the whole UI and git flows locally, without TLS or a reverse
+proxy, use the helper script:
+
+```sh
+scripts/local-uat.sh            # default port 8090
+scripts/local-uat.sh 9000       # or pick a port
+```
+
+It builds the binary, writes a throwaway config, creates the user
+(`josie` / `hunter2`), and serves on `127.0.0.1:<port>`. The binary and the
+whole data dir live in `.uat/` (gitignored); delete that directory to reset.
+Override with `UAT_DIR`, `UAT_USER`, `UAT_PASS`.
+
+Manual equivalent:
+
+```sh
+go build -o .uat/simplegit ./cmd/simplegit
+mkdir -p .uat/data
+printf 'data_dir = ".uat/data"\nlisten_addr = "127.0.0.1:8090"\nbase_url = "http://127.0.0.1:8090"\n' > .uat/sg.toml
+printf '%s\n' hunter2 | .uat/simplegit adduser -config .uat/sg.toml josie
+.uat/simplegit serve -config .uat/sg.toml
+```
+
+`base_url` is plain `http://` here, so the session cookie is not `Secure` —
+correct for localhost, wrong for production.
+
+Create a repo in the UI, then push with:
+
+```sh
+git -c credential.helper= push http://josie:hunter2@127.0.0.1:8090/josie/<repo>.git main
+```
+
+## 8. Backups
+
+Everything lives under `data_dir`:
+
+- `repos/<user>/<repo>.git` — the bare repositories (authoritative history)
+- `simplegit.db` (+ `-wal`, `-shm`) — users, sessions, tokens, issues, PRs,
+  releases metadata
+- `uploads/releases/<id>/` — release attachments
+
+Back up all three together; the DB and uploads reference each other. SQLite
+is in WAL mode, so snapshot with `sqlite3 simplegit.db ".backup <dest>"` (or
+copy the DB plus its `-wal`) rather than copying a live file blindly.
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..4d03087
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,25 @@
+module git.josie-c.com/josie/simplegit
+
+go 1.27.1
+
+require (
+	github.com/BurntSushi/toml v1.6.0
+	modernc.org/sqlite v1.60.1
+)
+
+require (
+	github.com/alecthomas/chroma/v2 v2.27.0 // indirect
+	github.com/dlclark/regexp2/v2 v2.2.1 // indirect
+	github.com/dustin/go-humanize v1.0.1 // indirect
+	github.com/google/uuid v1.6.0 // indirect
+	github.com/mattn/go-isatty v0.0.24 // indirect
+	github.com/ncruces/go-strftime v1.0.0 // indirect
+	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+	github.com/yuin/goldmark v1.8.6 // indirect
+	golang.org/x/crypto v0.57.0 // indirect
+	golang.org/x/sys v0.48.0 // indirect
+	golang.org/x/term v0.46.0 // indirect
+	modernc.org/libc v1.77.1 // indirect
+	modernc.org/mathutil v1.7.1 // indirect
+	modernc.org/memory v1.12.1 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..e65ba7a
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,62 @@
+github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
+github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
+github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
+github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
+github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
+github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
+github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
+github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
+golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
+golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
+golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
+golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
+golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
+golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
+golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
+golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
+golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
+golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
+modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
+modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
+modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=
+modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA=
+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
+modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
+modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
+modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
+modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
+modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/internal/auth/auth.go b/internal/auth/auth.go
new file mode 100644
index 0000000..5476f47
--- /dev/null
+++ b/internal/auth/auth.go
@@ -0,0 +1,64 @@
+// Package auth provides password hashing and opaque session tokens.
+package auth
+
+import (
+	"crypto/rand"
+	"crypto/sha256"
+	"encoding/base64"
+	"encoding/hex"
+	"fmt"
+
+	"golang.org/x/crypto/bcrypt"
+)
+
+// HashPassword returns a bcrypt hash of password.
+func HashPassword(password string) (string, error) {
+	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+	if err != nil {
+		return "", fmt.Errorf("hash password: %w", err)
+	}
+	return string(hash), nil
+}
+
+// CheckPassword reports whether password matches the stored hash.
+func CheckPassword(hash, password string) bool {
+	return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
+}
+
+// NewToken returns a random 256-bit hex string for a session cookie.
+func NewToken() (string, error) {
+	buf := make([]byte, 32)
+	if _, err := rand.Read(buf); err != nil {
+		return "", fmt.Errorf("generate token: %w", err)
+	}
+	return hex.EncodeToString(buf), nil
+}
+
+// apiTokenPrefix marks git tokens and tells them apart from passwords in
+// the HTTP basic-auth password field.
+const apiTokenPrefix = "sg_"
+
+// NewAPIToken returns a fresh git token: the prefix plus 32 random bytes.
+func NewAPIToken() (string, error) {
+	buf := make([]byte, 32)
+	if _, err := rand.Read(buf); err != nil {
+		return "", fmt.Errorf("generate api token: %w", err)
+	}
+	return apiTokenPrefix + base64.RawURLEncoding.EncodeToString(buf), nil
+}
+
+// HashToken returns the hex SHA-256 digest used to store and look up a git
+// token. A fast digest is correct here: the token is high-entropy, so there
+// is nothing to brute-force, and an indexed digest lookup keeps auth O(1).
+func HashToken(token string) string {
+	sum := sha256.Sum256([]byte(token))
+	return hex.EncodeToString(sum[:])
+}
+
+// TokenHint is the non-secret prefix shown in the token list.
+func TokenHint(token string) string {
+	if len(token) <= 8 {
+		return token
+	}
+	return token[:8]
+}
diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go
new file mode 100644
index 0000000..8c22ccd
--- /dev/null
+++ b/internal/auth/auth_test.go
@@ -0,0 +1,81 @@
+package auth
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestHashAndCheckPassword(t *testing.T) {
+	hash, err := HashPassword("correct horse")
+	if err != nil {
+		t.Fatalf("HashPassword: %v", err)
+	}
+	if !CheckPassword(hash, "correct horse") {
+		t.Error("CheckPassword with the same password = false, want true")
+	}
+	if CheckPassword(hash, "battery staple") {
+		t.Error("CheckPassword with wrong password = true, want false")
+	}
+	first, err := HashPassword("same password")
+	if err != nil {
+		t.Fatalf("HashPassword: %v", err)
+	}
+	second, err := HashPassword("same password")
+	if err != nil {
+		t.Fatalf("HashPassword: %v", err)
+	}
+	if first == second {
+		t.Error("two hashes of one password are identical, want salted")
+	}
+}
+
+func TestNewToken(t *testing.T) {
+	seen := map[string]bool{}
+	for range 10 {
+		token, err := NewToken()
+		if err != nil {
+			t.Fatalf("NewToken: %v", err)
+		}
+		if len(token) != 64 {
+			t.Errorf("token length = %d, want 64", len(token))
+		}
+		if seen[token] {
+			t.Errorf("token %s repeated", token)
+		}
+		seen[token] = true
+	}
+}
+
+func TestAPIToken(t *testing.T) {
+	seen := map[string]bool{}
+	for range 10 {
+		token, err := NewAPIToken()
+		if err != nil {
+			t.Fatalf("NewAPIToken: %v", err)
+		}
+		if !strings.HasPrefix(token, "sg_") {
+			t.Errorf("token %q lacks sg_ prefix", token)
+		}
+		if seen[token] {
+			t.Errorf("token %s repeated", token)
+		}
+		seen[token] = true
+	}
+
+	if HashToken("sg_abc") != HashToken("sg_abc") {
+		t.Error("HashToken is not deterministic")
+	}
+	if HashToken("sg_abc") == HashToken("sg_abd") {
+		t.Error("HashToken collided on differing input")
+	}
+	if len(HashToken("sg_abc")) != 64 {
+		t.Errorf("hash length = %d, want 64", len(HashToken("sg_abc")))
+	}
+
+	if hint := TokenHint("sg_abcdefghij"); hint != "sg_abcde" {
+		t.Errorf("TokenHint = %q, want sg_abcde", hint)
+	}
+	if hint := TokenHint("short"); hint != "short" {
+		t.Errorf("TokenHint(short) = %q, want short", hint)
+	}
+}
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..9d71fda
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,49 @@
+// Package config loads simplegit's runtime settings from a TOML file.
+package config
+
+import (
+	"errors"
+	"fmt"
+	"io/fs"
+
+	"github.com/BurntSushi/toml"
+)
+
+// Config holds the settings needed to run the server.
+type Config struct {
+	// DataDir is the root for runtime state: bare repos, the SQLite
+	// database, and uploads.
+	DataDir string `toml:"data_dir"`
+	// ListenAddr is the address the HTTP server binds. TLS is terminated
+	// by the reverse proxy, so this is plain HTTP.
+	ListenAddr string `toml:"listen_addr"`
+	// BaseURL is the public URL, used to build clone URLs and links.
+	BaseURL string `toml:"base_url"`
+}
+
+// Default returns the built-in configuration, used when no file is present.
+// Listen on loopback: the server speaks plain HTTP and must not be
+// reachable by anything that can see a public interface.
+func Default() Config {
+	return Config{
+		DataDir:    "data",
+		ListenAddr: "127.0.0.1:8080",
+		BaseURL:    "http://localhost:8080",
+	}
+}
+
+// Load reads path on top of the defaults. A missing file is not an error:
+// the defaults are returned so the binary runs out of the box.
+func Load(path string) (Config, error) {
+	cfg := Default()
+	if path == "" {
+		return cfg, nil
+	}
+	if _, err := toml.DecodeFile(path, &cfg); err != nil {
+		if errors.Is(err, fs.ErrNotExist) {
+			return cfg, nil
+		}
+		return Config{}, fmt.Errorf("load config %s: %w", path, err)
+	}
+	return cfg, nil
+}
diff --git a/internal/db/db.go b/internal/db/db.go
new file mode 100644
index 0000000..6e9fc5d
--- /dev/null
+++ b/internal/db/db.go
@@ -0,0 +1,165 @@
+// Package db owns the SQLite schema and all queries. Migrations are
+// embedded in the binary and applied at startup.
+package db
+
+import (
+	"database/sql"
+	"embed"
+	"fmt"
+	"io/fs"
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+
+	_ "modernc.org/sqlite"
+)
+
+//go:embed migrations/*.sql
+var migrationsFS embed.FS
+
+// Open opens the SQLite database at path, applies any pending migrations,
+// and returns a ready connection pool.
+func Open(path string) (*sql.DB, error) {
+	dsn := "file:" + path + "?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"
+	database, err := sql.Open("sqlite", dsn)
+	if err != nil {
+		return nil, fmt.Errorf("open sqlite %s: %w", path, err)
+	}
+	if err := database.Ping(); err != nil {
+		database.Close()
+		return nil, fmt.Errorf("open sqlite %s: %w", path, err)
+	}
+	// SQLite creates the file world-readable by default; it holds session
+	// tokens and password hashes, so tighten it regardless of umask.
+	if err := os.Chmod(path, 0o600); err != nil && !os.IsNotExist(err) {
+		database.Close()
+		return nil, fmt.Errorf("chmod sqlite %s: %w", path, err)
+	}
+	if err := migrate(database); err != nil {
+		database.Close()
+		return nil, err
+	}
+	return database, nil
+}
+
+// migrate applies every embedded migration not yet recorded in
+// schema_migrations, in filename order, each in its own transaction.
+func migrate(database *sql.DB) error {
+	if _, err := database.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
+		version    TEXT    PRIMARY KEY,
+		applied_at INTEGER NOT NULL
+	)`); err != nil {
+		return fmt.Errorf("create schema_migrations: %w", err)
+	}
+
+	names, err := fs.Glob(migrationsFS, "migrations/*.sql")
+	if err != nil {
+		return fmt.Errorf("list migrations: %w", err)
+	}
+	sort.Strings(names)
+
+	for _, name := range names {
+		version := filepath.Base(name)
+
+		var applied int
+		if err := database.QueryRow(
+			`SELECT COUNT(*) FROM schema_migrations WHERE version = ?`, version,
+		).Scan(&applied); err != nil {
+			return fmt.Errorf("check migration %s: %w", version, err)
+		}
+		if applied > 0 {
+			continue
+		}
+
+		script, err := migrationsFS.ReadFile(name)
+		if err != nil {
+			return fmt.Errorf("read migration %s: %w", version, err)
+		}
+		if err := apply(database, version, string(script)); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func apply(database *sql.DB, version, script string) error {
+	tx, err := database.Begin()
+	if err != nil {
+		return fmt.Errorf("begin migration %s: %w", version, err)
+	}
+	defer tx.Rollback()
+
+	if _, err := tx.Exec(script); err != nil {
+		return fmt.Errorf("apply migration %s: %w", version, err)
+	}
+	if _, err := tx.Exec(
+		`INSERT INTO schema_migrations (version, applied_at) VALUES (?, unixepoch())`, version,
+	); err != nil {
+		return fmt.Errorf("record migration %s: %w", version, err)
+	}
+	if err := tx.Commit(); err != nil {
+		return fmt.Errorf("commit migration %s: %w", version, err)
+	}
+	return nil
+}
+
+// rowScanner is satisfied by *sql.Row and *sql.Rows, letting entity
+// scanners serve both single-row lookups and list queries.
+type rowScanner interface {
+	Scan(dest ...any) error
+}
+
+// listQuery runs query and appends each row scanned by scan.
+func listQuery[T any](database *sql.DB, query string, args []any, scan func(rowScanner, *T) error) ([]T, error) {
+	rows, err := database.Query(query, args...)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var items []T
+	for rows.Next() {
+		var item T
+		if err := scan(rows, &item); err != nil {
+			return nil, err
+		}
+		items = append(items, item)
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}
+
+// execScoped runs a scoped UPDATE or DELETE and maps zero affected rows to
+// ErrNotFound, so callers never mistake "row absent" for success.
+func execScoped(database *sql.DB, op, query string, args ...any) error {
+	result, err := database.Exec(query, args...)
+	if err != nil {
+		return fmt.Errorf("%s: %w", op, err)
+	}
+	affected, err := result.RowsAffected()
+	if err != nil {
+		return fmt.Errorf("%s: %w", op, err)
+	}
+	if affected == 0 {
+		return fmt.Errorf("%s: %w", op, ErrNotFound)
+	}
+	return nil
+}
+
+// execLastID runs an INSERT and returns the new row id.
+func execLastID(database *sql.DB, query string, args ...any) (int64, error) {
+	result, err := database.Exec(query, args...)
+	if err != nil {
+		return 0, err
+	}
+	return result.LastInsertId()
+}
+
+// isUniqueViolation reports whether err is SQLite's UNIQUE constraint
+// failure — a per-repo number collision when it surfaces from an insert.
+func isUniqueViolation(err error) bool {
+	return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
+}
diff --git a/internal/db/db_test.go b/internal/db/db_test.go
new file mode 100644
index 0000000..a096ff0
--- /dev/null
+++ b/internal/db/db_test.go
@@ -0,0 +1,121 @@
+package db
+
+import (
+	"path/filepath"
+	"testing"
+)
+
+func TestOpenAppliesMigrations(t *testing.T) {
+	database, err := Open(filepath.Join(t.TempDir(), "test.db"))
+	if err != nil {
+		t.Fatalf("Open: %v", err)
+	}
+	defer database.Close()
+
+	for _, table := range []string{"users", "sessions", "repos", "tokens", "issues", "issue_comments", "pulls", "pull_comments", "releases", "release_assets", "schema_migrations"} {
+		var name string
+		err := database.QueryRow(
+			`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`, table,
+		).Scan(&name)
+		if err != nil {
+			t.Errorf("table %s missing: %v", table, err)
+		}
+	}
+
+	var applied int
+	if err := database.QueryRow(`SELECT COUNT(*) FROM schema_migrations`).Scan(&applied); err != nil {
+		t.Fatalf("count migrations: %v", err)
+	}
+	if applied != 5 {
+		t.Errorf("applied migrations = %d, want 5", applied)
+	}
+}
+
+func TestOpenIsIdempotent(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "test.db")
+
+	first, err := Open(path)
+	if err != nil {
+		t.Fatalf("first Open: %v", err)
+	}
+	first.Close()
+
+	second, err := Open(path)
+	if err != nil {
+		t.Fatalf("second Open: %v", err)
+	}
+	defer second.Close()
+
+	var applied int
+	if err := second.QueryRow(`SELECT COUNT(*) FROM schema_migrations`).Scan(&applied); err != nil {
+		t.Fatalf("count migrations: %v", err)
+	}
+	if applied != 5 {
+		t.Errorf("applied migrations after reopen = %d, want 5", applied)
+	}
+}
+
+func TestSchemaAcceptsRows(t *testing.T) {
+	database, err := Open(filepath.Join(t.TempDir(), "test.db"))
+	if err != nil {
+		t.Fatalf("Open: %v", err)
+	}
+	defer database.Close()
+
+	res, err := database.Exec(
+		`INSERT INTO users (username, password_hash) VALUES (?, ?)`, "josie", "x",
+	)
+	if err != nil {
+		t.Fatalf("insert user: %v", err)
+	}
+	userID, err := res.LastInsertId()
+	if err != nil {
+		t.Fatalf("last insert id: %v", err)
+	}
+
+	if _, err := database.Exec(
+		`INSERT INTO repos (owner_id, name) VALUES (?, ?)`, userID, "simplegit",
+	); err != nil {
+		t.Fatalf("insert repo: %v", err)
+	}
+
+	var visibility, branch string
+	var createdAt int64
+	if err := database.QueryRow(
+		`SELECT visibility, default_branch, created_at FROM repos WHERE owner_id = ? AND name = ?`,
+		userID, "simplegit",
+	).Scan(&visibility, &branch, &createdAt); err != nil {
+		t.Fatalf("select repo: %v", err)
+	}
+	if visibility != "private" {
+		t.Errorf("default visibility = %q, want private", visibility)
+	}
+	if branch != "main" {
+		t.Errorf("default branch = %q, want main", branch)
+	}
+	if createdAt <= 0 {
+		t.Errorf("created_at = %d, want > 0", createdAt)
+	}
+}
+
+func TestForeignKeysEnforced(t *testing.T) {
+	database, err := Open(filepath.Join(t.TempDir(), "test.db"))
+	if err != nil {
+		t.Fatalf("Open: %v", err)
+	}
+	defer database.Close()
+
+	var enabled int
+	if err := database.QueryRow(`PRAGMA foreign_keys`).Scan(&enabled); err != nil {
+		t.Fatalf("pragma foreign_keys: %v", err)
+	}
+	if enabled != 1 {
+		t.Fatalf("foreign_keys = %d, want 1", enabled)
+	}
+
+	if _, err := database.Exec(
+		`INSERT INTO repos (owner_id, name) VALUES (?, ?)`, 999, "orphan",
+	); err == nil {
+		t.Error("insert with missing owner_id succeeded, want foreign key error")
+	}
+}
diff --git a/internal/db/issues.go b/internal/db/issues.go
new file mode 100644
index 0000000..48f5aa0
--- /dev/null
+++ b/internal/db/issues.go
@@ -0,0 +1,255 @@
+package db
+
+import (
+	"database/sql"
+	"errors"
+	"fmt"
+)
+
+// Issue is a row in the issues table. AuthorID is NULL for guest-authored
+// rows; Pending marks guest content awaiting owner moderation.
+type Issue struct {
+	ID          int64
+	RepoID      int64
+	Number      int64
+	Title       string
+	Body        string
+	State       string
+	Pending     bool
+	AuthorID    sql.NullInt64
+	AuthorName  string
+	AuthorEmail string
+	CreatedAt   int64
+	ClosedAt    sql.NullInt64
+}
+
+// IssueComment is a row in the issue_comments table.
+type IssueComment struct {
+	ID          int64
+	IssueID     int64
+	Body        string
+	Pending     bool
+	AuthorID    sql.NullInt64
+	AuthorName  string
+	AuthorEmail string
+	CreatedAt   int64
+}
+
+const issueColumns = `SELECT id, repo_id, number, title, body, state, pending,
+	author_id, author_name, author_email, created_at, closed_at FROM issues WHERE `
+
+// CreateIssue inserts an issue with a per-repo number of MAX(number)+1,
+// computed atomically inside the INSERT. authorID is 0 for a guest.
+func CreateIssue(database *sql.DB, repoID int64, title, body string, authorID int64, authorName, authorEmail string, pending bool) (Issue, error) {
+	const insert = `INSERT INTO issues (repo_id, number, title, body, pending, author_id, author_name, author_email)
+		 VALUES (?, (SELECT COALESCE(MAX(number), 0) + 1 FROM issues WHERE repo_id = ?), ?, ?, ?, ?, ?, ?)`
+	args := []any{repoID, repoID, title, body, boolToInt(pending), nullableID(authorID), authorName, authorEmail}
+	// A concurrent insert can claim the computed number; retry the insert once
+	// (the subselect then recomputes MAX+1).
+	id, err := execLastID(database, insert, args...)
+	if err != nil && isUniqueViolation(err) {
+		id, err = execLastID(database, insert, args...)
+	}
+	if err != nil {
+		return Issue{}, fmt.Errorf("create issue: %w", err)
+	}
+	return GetIssueByID(database, id)
+}
+
+// GetIssueByID looks up an issue by primary key.
+func GetIssueByID(database *sql.DB, id int64) (Issue, error) {
+	return getIssue(database, issueColumns+`id = ?`, id)
+}
+
+// GetIssueByNumber looks up an issue by its per-repo number.
+func GetIssueByNumber(database *sql.DB, repoID, number int64) (Issue, error) {
+	return getIssue(database, issueColumns+`repo_id = ? AND number = ?`, repoID, number)
+}
+
+// scanIssue reads one issues row (see issueColumns) into issue.
+func scanIssue(s rowScanner, issue *Issue) error {
+	var pending int
+	if err := s.Scan(
+		&issue.ID, &issue.RepoID, &issue.Number, &issue.Title, &issue.Body,
+		&issue.State, &pending, &issue.AuthorID, &issue.AuthorName,
+		&issue.AuthorEmail, &issue.CreatedAt, &issue.ClosedAt); err != nil {
+		return err
+	}
+	issue.Pending = pending != 0
+	return nil
+}
+
+func getIssue(database *sql.DB, query string, args ...any) (Issue, error) {
+	var issue Issue
+	err := scanIssue(database.QueryRow(query, args...), &issue)
+	if errors.Is(err, sql.ErrNoRows) {
+		return Issue{}, fmt.Errorf("issue: %w", ErrNotFound)
+	}
+	if err != nil {
+		return Issue{}, fmt.Errorf("get issue: %w", err)
+	}
+	return issue, nil
+}
+
+// ListIssues returns a repo's issues. includePending must be true only for
+// the owner; state, when non-empty, filters to 'open' or 'closed'. Pending
+// rows sort first so the owner sees what needs review.
+func ListIssues(database *sql.DB, repoID int64, includePending bool, state string) ([]Issue, error) {
+	query := issueColumns + `repo_id = ?`
+	args := []any{repoID}
+	if !includePending {
+		query += ` AND pending = 0`
+	}
+	if state == "open" || state == "closed" {
+		query += ` AND state = ?`
+		args = append(args, state)
+	}
+	query += ` ORDER BY pending DESC, number DESC`
+
+	issues, err := listQuery(database, query, args, scanIssue)
+	if err != nil {
+		return nil, fmt.Errorf("list issues: %w", err)
+	}
+	return issues, nil
+}
+
+// SetIssueState flips an issue between open and closed (closed_at tracks the
+// close time). Scoped to the repo so a number cannot cross repos.
+func SetIssueState(database *sql.DB, repoID, number int64, state string) error {
+	if state != "open" && state != "closed" {
+		return fmt.Errorf("set issue state %q: invalid state", state)
+	}
+	query := `UPDATE issues SET state = ?, closed_at = NULL WHERE repo_id = ? AND number = ?`
+	args := []any{state, repoID, number}
+	if state == "closed" {
+		query = `UPDATE issues SET state = 'closed', closed_at = unixepoch() WHERE repo_id = ? AND number = ?`
+		args = []any{repoID, number}
+	}
+	return execScoped(database, "set issue state", query, args...)
+}
+
+// ApproveIssue publishes a pending issue.
+func ApproveIssue(database *sql.DB, repoID, number int64) error {
+	return execScoped(database, "approve issue",
+		`UPDATE issues SET pending = 0 WHERE repo_id = ? AND number = ?`, repoID, number)
+}
+
+// DeleteIssue removes an issue row; its comments cascade.
+func DeleteIssue(database *sql.DB, repoID, number int64) error {
+	return execScoped(database, "delete issue",
+		`DELETE FROM issues WHERE repo_id = ? AND number = ?`, repoID, number)
+}
+
+// HasDuplicateIssue reports whether a row with the same title, body, and
+// claimed author already exists in the repo — the dedupe behind guest filing.
+func HasDuplicateIssue(database *sql.DB, repoID int64, title, body, authorName, authorEmail string) (bool, error) {
+	var n int
+	err := database.QueryRow(
+		`SELECT COUNT(*) FROM issues WHERE repo_id = ? AND title = ? AND body = ?
+		 AND author_name = ? AND author_email = ?`,
+		repoID, title, body, authorName, authorEmail).Scan(&n)
+	if err != nil {
+		return false, fmt.Errorf("duplicate issue check: %w", err)
+	}
+	return n > 0, nil
+}
+
+const issueCommentColumns = `SELECT id, issue_id, body, pending, author_id,
+	author_name, author_email, created_at FROM issue_comments WHERE `
+
+// CreateComment inserts a comment. authorID is 0 for a guest.
+func CreateComment(database *sql.DB, issueID int64, body string, authorID int64, authorName, authorEmail string, pending bool) (IssueComment, error) {
+	id, err := execLastID(database,
+		`INSERT INTO issue_comments (issue_id, body, pending, author_id, author_name, author_email)
+		 VALUES (?, ?, ?, ?, ?, ?)`,
+		issueID, body, boolToInt(pending), nullableID(authorID), authorName, authorEmail)
+	if err != nil {
+		return IssueComment{}, fmt.Errorf("create comment: %w", err)
+	}
+	return getComment(database, issueCommentColumns+`id = ?`, id)
+}
+
+// scanIssueComment reads one issue_comments row into c.
+func scanIssueComment(s rowScanner, c *IssueComment) error {
+	var pending int
+	if err := s.Scan(
+		&c.ID, &c.IssueID, &c.Body, &pending, &c.AuthorID,
+		&c.AuthorName, &c.AuthorEmail, &c.CreatedAt); err != nil {
+		return err
+	}
+	c.Pending = pending != 0
+	return nil
+}
+
+func getComment(database *sql.DB, query string, args ...any) (IssueComment, error) {
+	var c IssueComment
+	err := scanIssueComment(database.QueryRow(query, args...), &c)
+	if errors.Is(err, sql.ErrNoRows) {
+		return IssueComment{}, fmt.Errorf("comment: %w", ErrNotFound)
+	}
+	if err != nil {
+		return IssueComment{}, fmt.Errorf("get comment: %w", err)
+	}
+	return c, nil
+}
+
+// ListComments returns an issue's comments oldest first. includePending must
+// be true only for the owner.
+func ListComments(database *sql.DB, issueID int64, includePending bool) ([]IssueComment, error) {
+	query := issueCommentColumns + `issue_id = ?`
+	if !includePending {
+		query += ` AND pending = 0`
+	}
+	query += ` ORDER BY created_at, id`
+
+	comments, err := listQuery(database, query, []any{issueID}, scanIssueComment)
+	if err != nil {
+		return nil, fmt.Errorf("list comments: %w", err)
+	}
+	return comments, nil
+}
+
+// ApproveComment publishes a pending comment, scoped to its issue so a
+// comment id cannot be approved through a different issue.
+func ApproveComment(database *sql.DB, issueID, id int64) error {
+	return execScoped(database, "approve comment",
+		`UPDATE issue_comments SET pending = 0 WHERE id = ? AND issue_id = ?`, id, issueID)
+}
+
+// DeleteComment removes a comment, scoped to its issue.
+func DeleteComment(database *sql.DB, issueID, id int64) error {
+	return execScoped(database, "delete comment",
+		`DELETE FROM issue_comments WHERE id = ? AND issue_id = ?`, id, issueID)
+}
+
+// CountPending returns how many issues, comments, pull requests, and PR
+// comments in a repo await owner moderation.
+func CountPending(database *sql.DB, repoID int64) (int, error) {
+	var n int
+	err := database.QueryRow(
+		`SELECT (SELECT COUNT(*) FROM issues WHERE repo_id = ? AND pending = 1)
+		      + (SELECT COUNT(*) FROM issue_comments c JOIN issues i ON i.id = c.issue_id
+		         WHERE i.repo_id = ? AND c.pending = 1)
+		      + (SELECT COUNT(*) FROM pulls WHERE repo_id = ? AND pending = 1)
+		      + (SELECT COUNT(*) FROM pull_comments c JOIN pulls p ON p.id = c.pull_id
+		         WHERE p.repo_id = ? AND c.pending = 1)`,
+		repoID, repoID, repoID, repoID).Scan(&n)
+	if err != nil {
+		return 0, fmt.Errorf("count pending: %w", err)
+	}
+	return n, nil
+}
+
+func boolToInt(b bool) int {
+	if b {
+		return 1
+	}
+	return 0
+}
+
+func nullableID(id int64) any {
+	if id == 0 {
+		return nil
+	}
+	return id
+}
diff --git a/internal/db/issues_test.go b/internal/db/issues_test.go
new file mode 100644
index 0000000..d73b873
--- /dev/null
+++ b/internal/db/issues_test.go
@@ -0,0 +1,218 @@
+package db
+
+import (
+	"errors"
+	"testing"
+)
+
+func TestCreateIssueNumbersPerRepo(t *testing.T) {
+	database := openTestDB(t)
+	ownerID, _ := CreateUser(database, "josie", "hash")
+	repoA, err := CreateRepo(database, ownerID, "alpha", "", "public")
+	if err != nil {
+		t.Fatalf("CreateRepo A: %v", err)
+	}
+	repoB, err := CreateRepo(database, ownerID, "beta", "", "public")
+	if err != nil {
+		t.Fatalf("CreateRepo B: %v", err)
+	}
+
+	first, err := CreateIssue(database, repoA.ID, "one", "body", ownerID, "josie", "", false)
+	if err != nil {
+		t.Fatalf("CreateIssue A1: %v", err)
+	}
+	second, err := CreateIssue(database, repoA.ID, "two", "body", ownerID, "josie", "", false)
+	if err != nil {
+		t.Fatalf("CreateIssue A2: %v", err)
+	}
+	other, err := CreateIssue(database, repoB.ID, "other", "body", 0, "guest", "g@example.com", true)
+	if err != nil {
+		t.Fatalf("CreateIssue B1: %v", err)
+	}
+
+	if first.Number != 1 || second.Number != 2 {
+		t.Errorf("repo A numbers = %d, %d, want 1, 2", first.Number, second.Number)
+	}
+	if other.Number != 1 {
+		t.Errorf("repo B number = %d, want 1 (independent)", other.Number)
+	}
+	if other.Pending != true {
+		t.Error("guest issue Pending = false, want true")
+	}
+	if other.AuthorID.Valid {
+		t.Error("guest issue AuthorID is set, want NULL")
+	}
+	if other.AuthorName != "guest" || other.AuthorEmail != "g@example.com" {
+		t.Errorf("guest author = %q/%q, want guest/g@example.com", other.AuthorName, other.AuthorEmail)
+	}
+	if !first.AuthorID.Valid || first.AuthorID.Int64 != ownerID {
+		t.Errorf("owner issue AuthorID = %+v, want %d", first.AuthorID, ownerID)
+	}
+}
+
+func TestGetIssueByNumberIsRepoScoped(t *testing.T) {
+	database := openTestDB(t)
+	ownerID, _ := CreateUser(database, "josie", "hash")
+	repoA, _ := CreateRepo(database, ownerID, "alpha", "", "public")
+	repoB, _ := CreateRepo(database, ownerID, "beta", "", "public")
+	if _, err := CreateIssue(database, repoA.ID, "a1", "", ownerID, "josie", "", false); err != nil {
+		t.Fatalf("CreateIssue: %v", err)
+	}
+
+	if _, err := GetIssueByNumber(database, repoB.ID, 1); !errors.Is(err, ErrNotFound) {
+		t.Errorf("GetIssueByNumber(other repo, 1) err = %v, want ErrNotFound", err)
+	}
+}
+
+func TestListIssuesPendingAndState(t *testing.T) {
+	database := openTestDB(t)
+	ownerID, _ := CreateUser(database, "josie", "hash")
+	repo, _ := CreateRepo(database, ownerID, "alpha", "", "public")
+
+	published, _ := CreateIssue(database, repo.ID, "published", "", ownerID, "josie", "", false)
+	pending, _ := CreateIssue(database, repo.ID, "pending", "", 0, "guest", "", true)
+
+	public, err := ListIssues(database, repo.ID, false, "")
+	if err != nil {
+		t.Fatalf("ListIssues public: %v", err)
+	}
+	if len(public) != 1 || public[0].Title != "published" {
+		t.Errorf("public list = %+v, want only the published issue", public)
+	}
+
+	owner, err := ListIssues(database, repo.ID, true, "")
+	if err != nil {
+		t.Fatalf("ListIssues owner: %v", err)
+	}
+	if len(owner) != 2 || !owner[0].Pending {
+		t.Errorf("owner list = %+v, want 2 with pending first", owner)
+	}
+
+	if err := SetIssueState(database, repo.ID, published.Number, "closed"); err != nil {
+		t.Fatalf("SetIssueState closed: %v", err)
+	}
+	closed, err := ListIssues(database, repo.ID, true, "closed")
+	if err != nil {
+		t.Fatalf("ListIssues closed: %v", err)
+	}
+	if len(closed) != 1 || closed[0].Number != published.Number {
+		t.Errorf("closed list = %+v, want the closed issue", closed)
+	}
+	open, err := ListIssues(database, repo.ID, true, "open")
+	if err != nil {
+		t.Fatalf("ListIssues open: %v", err)
+	}
+	if len(open) != 1 || open[0].Number != pending.Number {
+		t.Errorf("open list = %+v, want the pending issue", open)
+	}
+
+	reloaded, err := GetIssueByNumber(database, repo.ID, published.Number)
+	if err != nil {
+		t.Fatalf("GetIssueByNumber: %v", err)
+	}
+	if !reloaded.ClosedAt.Valid {
+		t.Error("ClosedAt is NULL after close")
+	}
+	if err := SetIssueState(database, repo.ID, published.Number, "open"); err != nil {
+		t.Fatalf("SetIssueState reopen: %v", err)
+	}
+	reopened, _ := GetIssueByNumber(database, repo.ID, published.Number)
+	if reopened.ClosedAt.Valid {
+		t.Error("ClosedAt is set after reopen, want NULL")
+	}
+}
+
+func TestApproveAndDeleteIssue(t *testing.T) {
+	database := openTestDB(t)
+	ownerID, _ := CreateUser(database, "josie", "hash")
+	repo, _ := CreateRepo(database, ownerID, "alpha", "", "public")
+	issue, _ := CreateIssue(database, repo.ID, "spam?", "", 0, "guest", "", true)
+
+	if err := ApproveIssue(database, repo.ID, issue.Number); err != nil {
+		t.Fatalf("ApproveIssue: %v", err)
+	}
+	got, _ := GetIssueByNumber(database, repo.ID, issue.Number)
+	if got.Pending {
+		t.Error("issue still pending after approve")
+	}
+
+	if err := DeleteIssue(database, repo.ID, issue.Number); err != nil {
+		t.Fatalf("DeleteIssue: %v", err)
+	}
+	if _, err := GetIssueByNumber(database, repo.ID, issue.Number); !errors.Is(err, ErrNotFound) {
+		t.Errorf("GetIssueByNumber after delete err = %v, want ErrNotFound", err)
+	}
+}
+
+func TestCommentsModerationAndCount(t *testing.T) {
+	database := openTestDB(t)
+	ownerID, _ := CreateUser(database, "josie", "hash")
+	repo, _ := CreateRepo(database, ownerID, "alpha", "", "public")
+	issue, _ := CreateIssue(database, repo.ID, "topic", "", ownerID, "josie", "", false)
+
+	ownerComment, _ := CreateComment(database, issue.ID, "owner reply", ownerID, "josie", "", false)
+	guestComment, _ := CreateComment(database, issue.ID, "guest reply", 0, "guest", "guest@example.com", true)
+
+	public, err := ListComments(database, issue.ID, false)
+	if err != nil {
+		t.Fatalf("ListComments public: %v", err)
+	}
+	if len(public) != 1 || public[0].ID != ownerComment.ID {
+		t.Errorf("public comments = %+v, want only the owner comment", public)
+	}
+
+	owner, err := ListComments(database, issue.ID, true)
+	if err != nil {
+		t.Fatalf("ListComments owner: %v", err)
+	}
+	if len(owner) != 2 {
+		t.Errorf("owner comments = %d, want 2", len(owner))
+	}
+
+	pending, err := CountPending(database, repo.ID)
+	if err != nil {
+		t.Fatalf("CountPending: %v", err)
+	}
+	if pending != 1 {
+		t.Errorf("CountPending = %d, want 1", pending)
+	}
+
+	if err := ApproveComment(database, issue.ID, guestComment.ID); err != nil {
+		t.Fatalf("ApproveComment: %v", err)
+	}
+	if pending, _ = CountPending(database, repo.ID); pending != 0 {
+		t.Errorf("CountPending after approve = %d, want 0", pending)
+	}
+
+	if err := DeleteComment(database, issue.ID, guestComment.ID); err != nil {
+		t.Fatalf("DeleteComment: %v", err)
+	}
+	if _, err := getComment(database, issueCommentColumns+`id = ?`, guestComment.ID); !errors.Is(err, ErrNotFound) {
+		t.Errorf("comment after delete err = %v, want ErrNotFound", err)
+	}
+
+	if err := ApproveComment(database, issue.ID+999, ownerComment.ID); !errors.Is(err, ErrNotFound) {
+		t.Errorf("ApproveComment wrong issue err = %v, want ErrNotFound", err)
+	}
+}
+
+func TestDeleteIssueCascadesComments(t *testing.T) {
+	database := openTestDB(t)
+	ownerID, _ := CreateUser(database, "josie", "hash")
+	repo, _ := CreateRepo(database, ownerID, "alpha", "", "public")
+	issue, _ := CreateIssue(database, repo.ID, "topic", "", ownerID, "josie", "", false)
+	if _, err := CreateComment(database, issue.ID, "reply", ownerID, "josie", "", false); err != nil {
+		t.Fatalf("CreateComment: %v", err)
+	}
+
+	if err := DeleteIssue(database, repo.ID, issue.Number); err != nil {
+		t.Fatalf("DeleteIssue: %v", err)
+	}
+	comments, err := ListComments(database, issue.ID, true)
+	if err != nil {
+		t.Fatalf("ListComments: %v", err)
+	}
+	if len(comments) != 0 {
+		t.Errorf("comments after issue delete = %d, want 0 (cascade)", len(comments))
+	}
+}
diff --git a/internal/db/migrations/0001_init.sql b/internal/db/migrations/0001_init.sql
new file mode 100644
index 0000000..22dee34
--- /dev/null
+++ b/internal/db/migrations/0001_init.sql
@@ -0,0 +1,39 @@
+-- 0001_init: users, sessions, repos.
+--
+-- Timestamps are unix epoch seconds (INTEGER) so they map straight onto
+-- Go's time.Time with no driver-specific parsing.
+
+-- Single-user host, but the table stays generic so CLI-added accounts
+-- remain possible later.
+CREATE TABLE users (
+    id            INTEGER PRIMARY KEY,
+    username      TEXT    NOT NULL UNIQUE,
+    password_hash TEXT    NOT NULL,
+    created_at    INTEGER NOT NULL DEFAULT (unixepoch())
+);
+
+-- Browser login sessions. token is a random opaque string.
+CREATE TABLE sessions (
+    token      TEXT    PRIMARY KEY,
+    user_id    INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    created_at INTEGER NOT NULL DEFAULT (unixepoch()),
+    expires_at INTEGER NOT NULL
+);
+
+CREATE INDEX sessions_user_id_idx ON sessions (user_id);
+
+-- One row per bare repo on disk at data/repos/{username}/{name}.git.
+CREATE TABLE repos (
+    id             INTEGER PRIMARY KEY,
+    owner_id       INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    name           TEXT    NOT NULL,
+    description    TEXT    NOT NULL DEFAULT '',
+    visibility     TEXT    NOT NULL DEFAULT 'private'
+                   CHECK (visibility IN ('public', 'private')),
+    default_branch TEXT    NOT NULL DEFAULT 'main',
+    created_at     INTEGER NOT NULL DEFAULT (unixepoch()),
+    pushed_at      INTEGER,
+    UNIQUE (owner_id, name)
+);
+
+CREATE INDEX repos_visibility_idx ON repos (visibility);
\ No newline at end of file
diff --git a/internal/db/migrations/0002_tokens.sql b/internal/db/migrations/0002_tokens.sql
new file mode 100644
index 0000000..e416a17
--- /dev/null
+++ b/internal/db/migrations/0002_tokens.sql
@@ -0,0 +1,16 @@
+-- 0002_tokens: HTTP basic-auth git tokens.
+--
+-- Tokens are long-lived push/clone credentials. Only the SHA-256 digest is
+-- stored; the plaintext is shown once at creation. hint is the non-secret
+-- prefix kept for display.
+CREATE TABLE tokens (
+    id           INTEGER PRIMARY KEY,
+    user_id      INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    name         TEXT    NOT NULL,
+    hint         TEXT    NOT NULL,
+    token_hash   TEXT    NOT NULL UNIQUE,
+    created_at   INTEGER NOT NULL DEFAULT (unixepoch()),
+    last_used_at INTEGER
+);
+
+CREATE INDEX tokens_user_id_idx ON tokens (user_id);
diff --git a/internal/db/migrations/0003_issues.sql b/internal/db/migrations/0003_issues.sql
new file mode 100644
index 0000000..3931e40
--- /dev/null
+++ b/internal/db/migrations/0003_issues.sql
@@ -0,0 +1,40 @@
+-- 0003_issues: per-repo issues and comments with guest filing + moderation.
+--
+-- Author identity: an authenticated author sets author_id (and author_name
+-- as a display copy); a guest leaves author_id NULL and supplies a
+-- self-claimed author_name plus optional author_email (owner-visible only).
+-- pending=1 hides guest content from the public until the owner approves it;
+-- owner-authored rows are never pending. number is per-repo (MAX+1).
+CREATE TABLE issues (
+    id           INTEGER PRIMARY KEY,
+    repo_id      INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
+    number       INTEGER NOT NULL,
+    title        TEXT    NOT NULL,
+    body         TEXT    NOT NULL DEFAULT '',
+    state        TEXT    NOT NULL DEFAULT 'open'
+                 CHECK (state IN ('open', 'closed')),
+    pending      INTEGER NOT NULL DEFAULT 0
+                 CHECK (pending IN (0, 1)),
+    author_id    INTEGER REFERENCES users(id) ON DELETE SET NULL,
+    author_name  TEXT    NOT NULL DEFAULT '',
+    author_email TEXT    NOT NULL DEFAULT '',
+    created_at   INTEGER NOT NULL DEFAULT (unixepoch()),
+    closed_at    INTEGER,
+    UNIQUE (repo_id, number)
+);
+
+CREATE INDEX issues_repo_id_idx ON issues (repo_id);
+
+CREATE TABLE issue_comments (
+    id           INTEGER PRIMARY KEY,
+    issue_id     INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
+    body         TEXT    NOT NULL,
+    pending      INTEGER NOT NULL DEFAULT 0
+                 CHECK (pending IN (0, 1)),
+    author_id    INTEGER REFERENCES users(id) ON DELETE SET NULL,
+    author_name  TEXT    NOT NULL DEFAULT '',
+    author_email TEXT    NOT NULL DEFAULT '',
+    created_at   INTEGER NOT NULL DEFAULT (unixepoch())
+);
+
+CREATE INDEX issue_comments_issue_id_idx ON issue_comments (issue_id);
diff --git a/internal/db/migrations/0004_pulls.sql b/internal/db/migrations/0004_pulls.sql
new file mode 100644
index 0000000..1334773
--- /dev/null
+++ b/internal/db/migrations/0004_pulls.sql
@@ -0,0 +1,44 @@
+-- 0004_pulls: branch-to-branch pull requests and PR-level comments.
+--
+-- Same authorship/moderation model as 0003_issues: an authenticated author
+-- sets author_id (+ a display author_name); a guest leaves author_id NULL
+-- and supplies a self-claimed name and optional email. pending=1 hides guest
+-- content until the owner approves it. base/head are branch names within the
+-- repo (no forks). number is per-repo (MAX+1).
+CREATE TABLE pulls (
+    id           INTEGER PRIMARY KEY,
+    repo_id      INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
+    number       INTEGER NOT NULL,
+    title        TEXT    NOT NULL,
+    body         TEXT    NOT NULL DEFAULT '',
+    base         TEXT    NOT NULL,
+    head         TEXT    NOT NULL,
+    state        TEXT    NOT NULL DEFAULT 'open'
+                 CHECK (state IN ('open', 'closed', 'merged')),
+    pending      INTEGER NOT NULL DEFAULT 0
+                 CHECK (pending IN (0, 1)),
+    author_id    INTEGER REFERENCES users(id) ON DELETE SET NULL,
+    author_name  TEXT    NOT NULL DEFAULT '',
+    author_email TEXT    NOT NULL DEFAULT '',
+    created_at   INTEGER NOT NULL DEFAULT (unixepoch()),
+    closed_at    INTEGER,
+    merged_at    INTEGER,
+    merge_commit TEXT    NOT NULL DEFAULT '',
+    UNIQUE (repo_id, number)
+);
+
+CREATE INDEX pulls_repo_id_idx ON pulls (repo_id);
+
+CREATE TABLE pull_comments (
+    id           INTEGER PRIMARY KEY,
+    pull_id      INTEGER NOT NULL REFERENCES pulls(id) ON DELETE CASCADE,
+    body         TEXT    NOT NULL,
+    pending      INTEGER NOT NULL DEFAULT 0
+                 CHECK (pending IN (0, 1)),
+    author_id    INTEGER REFERENCES users(id) ON DELETE SET NULL,
+    author_name  TEXT    NOT NULL DEFAULT '',
+    author_email TEXT    NOT NULL DEFAULT '',
+    created_at   INTEGER NOT NULL DEFAULT (unixepoch())
+);
+
+CREATE INDEX pull_comments_pull_id_idx ON pull_comments (pull_id);
diff --git a/internal/db/migrations/0005_releases.sql b/internal/db/migrations/0005_releases.sql
new file mode 100644
index 0000000..445716a
--- /dev/null
+++ b/internal/db/migrations/0005_releases.sql
@@ -0,0 +1,30 @@
+-- 0005_releases: releases attached to existing tags, with binary assets.
+--
+-- Unlike issues/pulls, releases are owner-only publish artifacts: there is
+-- no guest author and no moderation queue. A release names one tag that
+-- already exists in the repo (UNIQUE per repo). release_assets are files
+-- uploaded to the release; stored_name is the opaque on-disk name under
+-- uploads/releases/<release_id>/, while filename is what the user sees.
+CREATE TABLE releases (
+    id         INTEGER PRIMARY KEY,
+    repo_id    INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
+    tag        TEXT    NOT NULL,
+    title      TEXT    NOT NULL,
+    notes      TEXT    NOT NULL DEFAULT '',
+    author_id  INTEGER REFERENCES users(id) ON DELETE SET NULL,
+    created_at INTEGER NOT NULL DEFAULT (unixepoch()),
+    UNIQUE (repo_id, tag)
+);
+
+CREATE INDEX releases_repo_id_idx ON releases (repo_id);
+
+CREATE TABLE release_assets (
+    id          INTEGER PRIMARY KEY,
+    release_id  INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE,
+    filename    TEXT    NOT NULL,
+    stored_name TEXT    NOT NULL,
+    size        INTEGER NOT NULL,
+    created_at  INTEGER NOT NULL DEFAULT (unixepoch())
+);
+
+CREATE INDEX release_assets_release_id_idx ON release_assets (release_id);
diff --git a/internal/db/pulls.go b/internal/db/pulls.go
new file mode 100644
index 0000000..888d199
--- /dev/null
+++ b/internal/db/pulls.go
@@ -0,0 +1,235 @@
+package db
+
+import (
+	"database/sql"
+	"errors"
+	"fmt"
+)
+
+// Pull is a row in the pulls table. AuthorID is NULL for guest-authored
+// rows; Pending marks guest content awaiting owner moderation.
+type Pull struct {
+	ID          int64
+	RepoID      int64
+	Number      int64
+	Title       string
+	Body        string
+	Base        string
+	Head        string
+	State       string
+	Pending     bool
+	AuthorID    sql.NullInt64
+	AuthorName  string
+	AuthorEmail string
+	CreatedAt   int64
+	ClosedAt    sql.NullInt64
+	MergedAt    sql.NullInt64
+	MergeCommit string
+}
+
+// PullComment is a row in the pull_comments table.
+type PullComment struct {
+	ID          int64
+	PullID      int64
+	Body        string
+	Pending     bool
+	AuthorID    sql.NullInt64
+	AuthorName  string
+	AuthorEmail string
+	CreatedAt   int64
+}
+
+const pullColumns = `SELECT id, repo_id, number, title, body, base, head, state,
+	pending, author_id, author_name, author_email, created_at, closed_at,
+	merged_at, merge_commit FROM pulls WHERE `
+
+// CreatePull inserts a pull request with a per-repo number of MAX(number)+1,
+// computed atomically inside the INSERT. authorID is 0 for a guest.
+func CreatePull(database *sql.DB, repoID int64, title, body, base, head string, authorID int64, authorName, authorEmail string, pending bool) (Pull, error) {
+	const insert = `INSERT INTO pulls (repo_id, number, title, body, base, head, pending, author_id, author_name, author_email)
+		 VALUES (?, (SELECT COALESCE(MAX(number), 0) + 1 FROM pulls WHERE repo_id = ?), ?, ?, ?, ?, ?, ?, ?, ?)`
+	args := []any{repoID, repoID, title, body, base, head, boolToInt(pending), nullableID(authorID), authorName, authorEmail}
+	// A concurrent insert can claim the computed number; retry the insert once
+	// (the subselect then recomputes MAX+1).
+	id, err := execLastID(database, insert, args...)
+	if err != nil && isUniqueViolation(err) {
+		id, err = execLastID(database, insert, args...)
+	}
+	if err != nil {
+		return Pull{}, fmt.Errorf("create pull: %w", err)
+	}
+	return GetPullByID(database, id)
+}
+
+// GetPullByID looks up a pull request by primary key.
+func GetPullByID(database *sql.DB, id int64) (Pull, error) {
+	return getPull(database, pullColumns+`id = ?`, id)
+}
+
+// GetPullByNumber looks up a pull request by its per-repo number.
+func GetPullByNumber(database *sql.DB, repoID, number int64) (Pull, error) {
+	return getPull(database, pullColumns+`repo_id = ? AND number = ?`, repoID, number)
+}
+
+// scanPull reads one pulls row (see pullColumns) into p.
+func scanPull(s rowScanner, p *Pull) error {
+	var pending int
+	if err := s.Scan(
+		&p.ID, &p.RepoID, &p.Number, &p.Title, &p.Body, &p.Base, &p.Head,
+		&p.State, &pending, &p.AuthorID, &p.AuthorName, &p.AuthorEmail,
+		&p.CreatedAt, &p.ClosedAt, &p.MergedAt, &p.MergeCommit); err != nil {
+		return err
+	}
+	p.Pending = pending != 0
+	return nil
+}
+
+func getPull(database *sql.DB, query string, args ...any) (Pull, error) {
+	var p Pull
+	err := scanPull(database.QueryRow(query, args...), &p)
+	if errors.Is(err, sql.ErrNoRows) {
+		return Pull{}, fmt.Errorf("pull: %w", ErrNotFound)
+	}
+	if err != nil {
+		return Pull{}, fmt.Errorf("get pull: %w", err)
+	}
+	return p, nil
+}
+
+// ListPulls returns a repo's pull requests. includePending must be true only
+// for the owner; state, when non-empty, filters to 'open', 'closed', or
+// 'merged'. Pending rows sort first so the owner sees what needs review.
+func ListPulls(database *sql.DB, repoID int64, includePending bool, state string) ([]Pull, error) {
+	query := pullColumns + `repo_id = ?`
+	args := []any{repoID}
+	if !includePending {
+		query += ` AND pending = 0`
+	}
+	switch state {
+	case "open", "closed", "merged":
+		query += ` AND state = ?`
+		args = append(args, state)
+	}
+	query += ` ORDER BY pending DESC, number DESC`
+
+	pulls, err := listQuery(database, query, args, scanPull)
+	if err != nil {
+		return nil, fmt.Errorf("list pulls: %w", err)
+	}
+	return pulls, nil
+}
+
+// SetPullState opens or closes a pull request (closed_at tracks the close
+// time). Scoped to the repo so a number cannot cross repos.
+func SetPullState(database *sql.DB, repoID, number int64, state string) error {
+	if state != "open" && state != "closed" {
+		return fmt.Errorf("set pull state %q: invalid state", state)
+	}
+	query := `UPDATE pulls SET state = ?, closed_at = NULL WHERE repo_id = ? AND number = ?`
+	args := []any{state, repoID, number}
+	if state == "closed" {
+		query = `UPDATE pulls SET state = 'closed', closed_at = unixepoch() WHERE repo_id = ? AND number = ?`
+		args = []any{repoID, number}
+	}
+	return execScoped(database, "set pull state", query, args...)
+}
+
+// SetPullMerged records a completed merge.
+func SetPullMerged(database *sql.DB, repoID, number int64, mergeCommit string) error {
+	return execScoped(database, "set pull merged",
+		`UPDATE pulls SET state = 'merged', merged_at = unixepoch(), merge_commit = ? WHERE repo_id = ? AND number = ?`,
+		mergeCommit, repoID, number)
+}
+
+// ApprovePull publishes a pending pull request.
+func ApprovePull(database *sql.DB, repoID, number int64) error {
+	return execScoped(database, "approve pull",
+		`UPDATE pulls SET pending = 0 WHERE repo_id = ? AND number = ?`, repoID, number)
+}
+
+// DeletePull removes a pull request; its comments cascade.
+func DeletePull(database *sql.DB, repoID, number int64) error {
+	return execScoped(database, "delete pull",
+		`DELETE FROM pulls WHERE repo_id = ? AND number = ?`, repoID, number)
+}
+
+// HasDuplicatePull reports whether a row with the same title, body, branch
+// pair, and claimed author already exists in the repo.
+func HasDuplicatePull(database *sql.DB, repoID int64, title, body, base, head, authorName, authorEmail string) (bool, error) {
+	var n int
+	err := database.QueryRow(
+		`SELECT COUNT(*) FROM pulls WHERE repo_id = ? AND title = ? AND body = ?
+		 AND base = ? AND head = ? AND author_name = ? AND author_email = ?`,
+		repoID, title, body, base, head, authorName, authorEmail).Scan(&n)
+	if err != nil {
+		return false, fmt.Errorf("duplicate pull check: %w", err)
+	}
+	return n > 0, nil
+}
+
+const pullCommentColumns = `SELECT id, pull_id, body, pending, author_id,
+	author_name, author_email, created_at FROM pull_comments WHERE `
+
+// CreatePullComment inserts a PR-level comment. authorID is 0 for a guest.
+func CreatePullComment(database *sql.DB, pullID int64, body string, authorID int64, authorName, authorEmail string, pending bool) (PullComment, error) {
+	id, err := execLastID(database,
+		`INSERT INTO pull_comments (pull_id, body, pending, author_id, author_name, author_email)
+		 VALUES (?, ?, ?, ?, ?, ?)`,
+		pullID, body, boolToInt(pending), nullableID(authorID), authorName, authorEmail)
+	if err != nil {
+		return PullComment{}, fmt.Errorf("create pull comment: %w", err)
+	}
+	return getPullComment(database, pullCommentColumns+`id = ?`, id)
+}
+
+// scanPullComment reads one pull_comments row into c.
+func scanPullComment(s rowScanner, c *PullComment) error {
+	var pending int
+	if err := s.Scan(
+		&c.ID, &c.PullID, &c.Body, &pending, &c.AuthorID,
+		&c.AuthorName, &c.AuthorEmail, &c.CreatedAt); err != nil {
+		return err
+	}
+	c.Pending = pending != 0
+	return nil
+}
+
+func getPullComment(database *sql.DB, query string, args ...any) (PullComment, error) {
+	var c PullComment
+	err := scanPullComment(database.QueryRow(query, args...), &c)
+	if errors.Is(err, sql.ErrNoRows) {
+		return PullComment{}, fmt.Errorf("pull comment: %w", ErrNotFound)
+	}
+	if err != nil {
+		return PullComment{}, fmt.Errorf("get pull comment: %w", err)
+	}
+	return c, nil
+}
+
+// ListPullComments returns a pull request's comments oldest first.
+// includePending must be true only for the owner.
+func ListPullComments(database *sql.DB, pullID int64, includePending bool) ([]PullComment, error) {
+	query := pullCommentColumns + `pull_id = ?`
+	if !includePending {
+		query += ` AND pending = 0`
+	}
+	query += ` ORDER BY created_at, id`
+
+	comments, err := listQuery(database, query, []any{pullID}, scanPullComment)
+	if err != nil {
+		return nil, fmt.Errorf("list pull comments: %w", err)
+	}
+	return comments, nil
+}
+
+// ApprovePullComment publishes a pending comment, scoped to its pull.
+func ApprovePullComment(database *sql.DB, pullID, id int64) error {
+	return execScoped(database, "approve pull comment",
+		`UPDATE pull_comments SET pending = 0 WHERE id = ? AND pull_id = ?`, id, pullID)
+}
+
+// DeletePullComment removes a comment, scoped to its pull.
+func DeletePullComment(database *sql.DB, pullID, id int64) error {
+	return execScoped(database, "delete pull comment",
+		`DELETE FROM pull_comments WHERE id = ? AND pull_id = ?`, id, pullID)
+}
diff --git a/internal/db/pulls_test.go b/internal/db/pulls_test.go
new file mode 100644
index 0000000..46fbf6a
--- /dev/null
+++ b/internal/db/pulls_test.go
@@ -0,0 +1,168 @@
+package db
+
+import (
+	"errors"
+	"testing"
+)
+
+func TestCreatePullNumbersPerRepo(t *testing.T) {
+	database := openTestDB(t)
+	ownerID, _ := CreateUser(database, "josie", "hash")
+	repoA, _ := CreateRepo(database, ownerID, "alpha", "", "public")
+	repoB, _ := CreateRepo(database, ownerID, "beta", "", "public")
+
+	first, err := CreatePull(database, repoA.ID, "one", "body", "main", "feature", ownerID, "josie", "", false)
+	if err != nil {
+		t.Fatalf("CreatePull A1: %v", err)
+	}
+	second, err := CreatePull(database, repoA.ID, "two", "body", "main", "feature2", ownerID, "josie", "", false)
+	if err != nil {
+		t.Fatalf("CreatePull A2: %v", err)
+	}
+	other, err := CreatePull(database, repoB.ID, "other", "body", "main", "feature", 0, "guest", "[EMAIL]", true)
+	if err != nil {
+		t.Fatalf("CreatePull B1: %v", err)
+	}
+
+	if first.Number != 1 || second.Number != 2 || other.Number != 1 {
+		t.Errorf("numbers = %d, %d, %d, want 1, 2, 1", first.Number, second.Number, other.Number)
+	}
+	if first.State != "open" {
+		t.Errorf("default state = %q, want open", first.State)
+	}
+	if !other.Pending || other.AuthorID.Valid {
+		t.Errorf("guest pull = %+v, want pending with NULL author_id", other)
+	}
+	if !first.AuthorID.Valid || first.AuthorID.Int64 != ownerID {
+		t.Errorf("owner pull AuthorID = %+v, want %d", first.AuthorID, ownerID)
+	}
+	if _, err := GetPullByNumber(database, repoB.ID, 2); !errors.Is(err, ErrNotFound) {
+		t.Errorf("cross-repo pull err = %v, want ErrNotFound", err)
+	}
+}
+
+func TestListPullsFilters(t *testing.T) {
+	database := openTestDB(t)
+	ownerID, _ := CreateUser(database, "josie", "hash")
+	repo, _ := CreateRepo(database, ownerID, "alpha", "", "public")
+
+	open, _ := CreatePull(database, repo.ID, "open", "", "main", "feature", ownerID, "josie", "", false)
+	closed, _ := CreatePull(database, repo.ID, "closed", "", "main", "feature2", ownerID, "josie", "", false)
+	merged, _ := CreatePull(database, repo.ID, "merged", "", "main", "feature3", ownerID, "josie", "", false)
+	pending, _ := CreatePull(database, repo.ID, "pending", "", "main", "feature4", 0, "guest", "", true)
+
+	if err := SetPullState(database, repo.ID, closed.Number, "closed"); err != nil {
+		t.Fatalf("SetPullState closed: %v", err)
+	}
+	if err := SetPullMerged(database, repo.ID, merged.Number, "deadbeef"); err != nil {
+		t.Fatalf("SetPullMerged: %v", err)
+	}
+
+	public, err := ListPulls(database, repo.ID, false, "")
+	if err != nil {
+		t.Fatalf("ListPulls public: %v", err)
+	}
+	if len(public) != 3 {
+		t.Errorf("public pulls = %d, want 3 (pending hidden)", len(public))
+	}
+	owner, _ := ListPulls(database, repo.ID, true, "")
+	if len(owner) != 4 || !owner[0].Pending {
+		t.Errorf("owner pulls = %+v, want 4 with pending first", owner)
+	}
+
+	openList, _ := ListPulls(database, repo.ID, true, "open")
+	if len(openList) != 2 {
+		t.Errorf("open pulls = %d, want 2 (open + pending)", len(openList))
+	}
+	closedList, _ := ListPulls(database, repo.ID, true, "closed")
+	if len(closedList) != 1 || closedList[0].Number != closed.Number {
+		t.Errorf("closed pulls = %+v, want the closed one", closedList)
+	}
+	mergedList, _ := ListPulls(database, repo.ID, true, "merged")
+	if len(mergedList) != 1 || mergedList[0].MergeCommit != "deadbeef" || !mergedList[0].MergedAt.Valid {
+		t.Errorf("merged pulls = %+v, want merged with commit and timestamp", mergedList)
+	}
+	_ = open
+	_ = pending
+}
+
+func TestSetPullStateReopen(t *testing.T) {
+	database := openTestDB(t)
+	ownerID, _ := CreateUser(database, "josie", "hash")
+	repo, _ := CreateRepo(database, ownerID, "alpha", "", "public")
+	pull, _ := CreatePull(database, repo.ID, "t", "", "main", "feature", ownerID, "josie", "", false)
+
+	if err := SetPullState(database, repo.ID, pull.Number, "closed"); err != nil {
+		t.Fatalf("close: %v", err)
+	}
+	closed, _ := GetPullByNumber(database, repo.ID, pull.Number)
+	if closed.State != "closed" || !closed.ClosedAt.Valid {
+		t.Errorf("after close = %+v, want closed with closed_at", closed)
+	}
+	if err := SetPullState(database, repo.ID, pull.Number, "open"); err != nil {
+		t.Fatalf("reopen: %v", err)
+	}
+	reopened, _ := GetPullByNumber(database, repo.ID, pull.Number)
+	if reopened.ClosedAt.Valid {
+		t.Error("closed_at set after reopen, want NULL")
+	}
+}
+
+func TestApproveAndDeletePull(t *testing.T) {
+	database := openTestDB(t)
+	ownerID, _ := CreateUser(database, "josie", "hash")
+	repo, _ := CreateRepo(database, ownerID, "alpha", "", "public")
+	pull, _ := CreatePull(database, repo.ID, "guest pr", "", "main", "feature", 0, "guest", "", true)
+	if _, err := CreatePullComment(database, pull.ID, "reply", ownerID, "josie", "", false); err != nil {
+		t.Fatalf("CreatePullComment: %v", err)
+	}
+
+	if pending, _ := CountPending(database, repo.ID); pending != 1 {
+		t.Errorf("CountPending = %d, want 1", pending)
+	}
+	if err := ApprovePull(database, repo.ID, pull.Number); err != nil {
+		t.Fatalf("ApprovePull: %v", err)
+	}
+	got, _ := GetPullByNumber(database, repo.ID, pull.Number)
+	if got.Pending {
+		t.Error("pull still pending after approve")
+	}
+
+	if err := DeletePull(database, repo.ID, pull.Number); err != nil {
+		t.Fatalf("DeletePull: %v", err)
+	}
+	if _, err := GetPullByNumber(database, repo.ID, pull.Number); !errors.Is(err, ErrNotFound) {
+		t.Errorf("pull after delete err = %v, want ErrNotFound", err)
+	}
+	comments, _ := ListPullComments(database, pull.ID, true)
+	if len(comments) != 0 {
+		t.Errorf("comments after cascade = %d, want 0", len(comments))
+	}
+}
+
+func TestPullCommentsModeration(t *testing.T) {
+	database := openTestDB(t)
+	ownerID, _ := CreateUser(database, "josie", "hash")
+	repo, _ := CreateRepo(database, ownerID, "alpha", "", "public")
+	pull, _ := CreatePull(database, repo.ID, "t", "", "main", "feature", ownerID, "josie", "", false)
+
+	ownerComment, _ := CreatePullComment(database, pull.ID, "owner", ownerID, "josie", "", false)
+	guestComment, _ := CreatePullComment(database, pull.ID, "guest", 0, "guest", "", true)
+
+	public, _ := ListPullComments(database, pull.ID, false)
+	if len(public) != 1 || public[0].ID != ownerComment.ID {
+		t.Errorf("public comments = %+v, want only the owner one", public)
+	}
+	if err := ApprovePullComment(database, pull.ID, guestComment.ID); err != nil {
+		t.Fatalf("ApprovePullComment: %v", err)
+	}
+	if pending, _ := CountPending(database, repo.ID); pending != 0 {
+		t.Errorf("CountPending after approve = %d, want 0", pending)
+	}
+	if err := DeletePullComment(database, pull.ID, guestComment.ID); err != nil {
+		t.Fatalf("DeletePullComment: %v", err)
+	}
+	if err := ApprovePullComment(database, pull.ID+999, ownerComment.ID); !errors.Is(err, ErrNotFound) {
+		t.Errorf("approve wrong pull err = %v, want ErrNotFound", err)
+	}
+}
diff --git a/internal/db/releases.go b/internal/db/releases.go
new file mode 100644
index 0000000..4592775
--- /dev/null
+++ b/internal/db/releases.go
@@ -0,0 +1,148 @@
+package db
+
+import (
+	"database/sql"
+	"errors"
+	"fmt"
+)
+
+// ErrReleaseExists marks a tag that already has a release in the repo.
+var ErrReleaseExists = errors.New("release already exists for tag")
+
+// Release is a row in the releases table: a title and markdown notes
+// attached to a tag that already exists in the repository.
+type Release struct {
+	ID        int64
+	RepoID    int64
+	Tag       string
+	Title     string
+	Notes     string
+	AuthorID  sql.NullInt64
+	CreatedAt int64
+}
+
+// ReleaseAsset is a file uploaded to a release. StoredName is the opaque
+// name on disk; Filename is the name shown to users.
+type ReleaseAsset struct {
+	ID         int64
+	ReleaseID  int64
+	Filename   string
+	StoredName string
+	Size       int64
+	CreatedAt  int64
+}
+
+const releaseColumns = `SELECT id, repo_id, tag, title, notes, author_id, created_at FROM releases WHERE `
+
+// CreateRelease stores a release for repoID's tag. A tag may have at most
+// one release (UNIQUE(repo_id, tag)); a duplicate returns ErrReleaseExists.
+func CreateRelease(database *sql.DB, repoID int64, tag, title, notes string, authorID int64) (Release, error) {
+	id, err := execLastID(database,
+		`INSERT INTO releases (repo_id, tag, title, notes, author_id) VALUES (?, ?, ?, ?, ?)`,
+		repoID, tag, title, notes, nullableID(authorID))
+	if err != nil {
+		if isUniqueViolation(err) {
+			return Release{}, fmt.Errorf("create release %s: %w", tag, ErrReleaseExists)
+		}
+		return Release{}, fmt.Errorf("create release: %w", err)
+	}
+	return GetReleaseByID(database, id)
+}
+
+// GetReleaseByID looks up a release by primary key.
+func GetReleaseByID(database *sql.DB, id int64) (Release, error) {
+	return getRelease(database, releaseColumns+`id = ?`, id)
+}
+
+// GetReleaseByTag looks up a repo's release by tag.
+func GetReleaseByTag(database *sql.DB, repoID int64, tag string) (Release, error) {
+	return getRelease(database, releaseColumns+`repo_id = ? AND tag = ?`, repoID, tag)
+}
+
+// scanRelease reads one releases row (see releaseColumns) into r.
+func scanRelease(s rowScanner, r *Release) error {
+	return s.Scan(&r.ID, &r.RepoID, &r.Tag, &r.Title, &r.Notes, &r.AuthorID, &r.CreatedAt)
+}
+
+func getRelease(database *sql.DB, query string, args ...any) (Release, error) {
+	var r Release
+	err := scanRelease(database.QueryRow(query, args...), &r)
+	if errors.Is(err, sql.ErrNoRows) {
+		return Release{}, fmt.Errorf("release: %w", ErrNotFound)
+	}
+	if err != nil {
+		return Release{}, fmt.Errorf("get release: %w", err)
+	}
+	return r, nil
+}
+
+// ListReleases returns a repo's releases, newest first.
+func ListReleases(database *sql.DB, repoID int64) ([]Release, error) {
+	releases, err := listQuery(database,
+		releaseColumns+`repo_id = ? ORDER BY created_at DESC, id DESC`, []any{repoID}, scanRelease)
+	if err != nil {
+		return nil, fmt.Errorf("list releases: %w", err)
+	}
+	return releases, nil
+}
+
+// DeleteRelease removes a release, scoped to its repo; its assets cascade.
+func DeleteRelease(database *sql.DB, repoID, id int64) error {
+	return execScoped(database, "delete release",
+		`DELETE FROM releases WHERE id = ? AND repo_id = ?`, id, repoID)
+}
+
+const releaseAssetColumns = `SELECT id, release_id, filename, stored_name, size, created_at FROM release_assets WHERE `
+
+// CreateReleaseAsset records an uploaded file on a release.
+func CreateReleaseAsset(database *sql.DB, releaseID int64, filename, storedName string, size int64) (ReleaseAsset, error) {
+	id, err := execLastID(database,
+		`INSERT INTO release_assets (release_id, filename, stored_name, size) VALUES (?, ?, ?, ?)`,
+		releaseID, filename, storedName, size)
+	if err != nil {
+		return ReleaseAsset{}, fmt.Errorf("create release asset: %w", err)
+	}
+	return getReleaseAsset(database, releaseAssetColumns+`id = ?`, id)
+}
+
+// scanReleaseAsset reads one release_assets row into a.
+func scanReleaseAsset(s rowScanner, a *ReleaseAsset) error {
+	return s.Scan(&a.ID, &a.ReleaseID, &a.Filename, &a.StoredName, &a.Size, &a.CreatedAt)
+}
+
+func getReleaseAsset(database *sql.DB, query string, args ...any) (ReleaseAsset, error) {
+	var a ReleaseAsset
+	err := scanReleaseAsset(database.QueryRow(query, args...), &a)
+	if errors.Is(err, sql.ErrNoRows) {
+		return ReleaseAsset{}, fmt.Errorf("release asset: %w", ErrNotFound)
+	}
+	if err != nil {
+		return ReleaseAsset{}, fmt.Errorf("get release asset: %w", err)
+	}
+	return a, nil
+}
+
+// ListReleaseAssets returns a release's assets oldest first.
+func ListReleaseAssets(database *sql.DB, releaseID int64) ([]ReleaseAsset, error) {
+	assets, err := listQuery(database,
+		releaseAssetColumns+`release_id = ? ORDER BY created_at, id`, []any{releaseID}, scanReleaseAsset)
+	if err != nil {
+		return nil, fmt.Errorf("list release assets: %w", err)
+	}
+	return assets, nil
+}
+
+// GetReleaseAssetForRepo looks up an asset scoped to a repo, so a download
+// cannot reach another repo's release by guessing an id.
+func GetReleaseAssetForRepo(database *sql.DB, repoID, id int64) (ReleaseAsset, error) {
+	return getReleaseAsset(database,
+		`SELECT a.id, a.release_id, a.filename, a.stored_name, a.size, a.created_at
+		 FROM release_assets a JOIN releases r ON r.id = a.release_id
+		 WHERE a.id = ? AND r.repo_id = ?`, id, repoID)
+}
+
+// DeleteReleaseAsset removes an asset, scoped to its release.
+func DeleteReleaseAsset(database *sql.DB, releaseID, id int64) error {
+	return execScoped(database, "delete release asset",
+		`DELETE FROM release_assets WHERE id = ? AND release_id = ?`, id, releaseID)
+}
diff --git a/internal/db/releases_test.go b/internal/db/releases_test.go
new file mode 100644
index 0000000..b76de25
--- /dev/null
+++ b/internal/db/releases_test.go
@@ -0,0 +1,99 @@
+package db
+
+import (
+	"errors"
+	"testing"
+)
+
+func TestCreateReleaseAndLookup(t *testing.T) {
+	database := openTestDB(t)
+	ownerID, _ := CreateUser(database, "josie", "hash")
+	repo, _ := CreateRepo(database, ownerID, "alpha", "", "public")
+	other, _ := CreateRepo(database, ownerID, "beta", "", "public")
+
+	first, err := CreateRelease(database, repo.ID, "v1.0.0", "One", "notes **one**", ownerID)
+	if err != nil {
+		t.Fatalf("CreateRelease: %v", err)
+	}
+	if first.Tag != "v1.0.0" || first.Title != "One" || !first.AuthorID.Valid || first.AuthorID.Int64 != ownerID {
+		t.Errorf("release = %+v, want tag/title/author set", first)
+	}
+
+	second, err := CreateRelease(database, repo.ID, "v1.0.1", "Two", "notes two", ownerID)
+	if err != nil {
+		t.Fatalf("CreateRelease second: %v", err)
+	}
+
+	if got, err := GetReleaseByID(database, first.ID); err != nil || got.Tag != "v1.0.0" {
+		t.Errorf("GetReleaseByID = (%+v, %v), want v1.0.0", got, err)
+	}
+	if got, err := GetReleaseByTag(database, repo.ID, "v1.0.0"); err != nil || got.ID != first.ID {
+		t.Errorf("GetReleaseByTag = (%+v, %v), want id %d", got, err, first.ID)
+	}
+	if _, err := GetReleaseByTag(database, other.ID, "v1.0.0"); !errors.Is(err, ErrNotFound) {
+		t.Errorf("cross-repo GetReleaseByTag err = %v, want ErrNotFound", err)
+	}
+
+	list, err := ListReleases(database, repo.ID)
+	if err != nil {
+		t.Fatalf("ListReleases: %v", err)
+	}
+	if len(list) != 2 || list[0].ID != second.ID {
+		t.Errorf("ListReleases = %+v, want newest (v1.0.1) first", list)
+	}
+
+	if _, err := CreateRelease(database, repo.ID, "v1.0.0", "dupe", "", ownerID); !errors.Is(err, ErrReleaseExists) {
+		t.Errorf("duplicate CreateRelease err = %v, want ErrReleaseExists", err)
+	}
+}
+
+func TestDeleteReleaseScoped(t *testing.T) {
+	database := openTestDB(t)
+	ownerID, _ := CreateUser(database, "josie", "hash")
+	repo, _ := CreateRepo(database, ownerID, "alpha", "", "public")
+	other, _ := CreateRepo(database, ownerID, "beta", "", "public")
+	release, _ := CreateRelease(database, repo.ID, "v1", "One", "", ownerID)
+
+	if err := DeleteRelease(database, other.ID, release.ID); !errors.Is(err, ErrNotFound) {
+		t.Errorf("cross-repo DeleteRelease err = %v, want ErrNotFound", err)
+	}
+	if err := DeleteRelease(database, repo.ID, release.ID); err != nil {
+		t.Fatalf("DeleteRelease: %v", err)
+	}
+	if _, err := GetReleaseByID(database, release.ID); !errors.Is(err, ErrNotFound) {
+		t.Errorf("release after delete err = %v, want ErrNotFound", err)
+	}
+}
+
+func TestReleaseAssets(t *testing.T) {
+	database := openTestDB(t)
+	ownerID, _ := CreateUser(database, "josie", "hash")
+	repo, _ := CreateRepo(database, ownerID, "alpha", "", "public")
+	other, _ := CreateRepo(database, ownerID, "beta", "", "public")
+	release, _ := CreateRelease(database, repo.ID, "v1", "One", "", ownerID)
+
+	asset, err := CreateReleaseAsset(database, release.ID, "simplegit-linux", "deadbeef", 12345)
+	if err != nil {
+		t.Fatalf("CreateReleaseAsset: %v", err)
+	}
+	if asset.Filename != "simplegit-linux" || asset.StoredName != "deadbeef" || asset.Size != 12345 {
+		t.Errorf("asset = %+v", asset)
+	}
+
+	assets, err := ListReleaseAssets(database, release.ID)
+	if err != nil || len(assets) != 1 {
+		t.Fatalf("ListReleaseAssets = (%+v, %v), want one", assets, err)
+	}
+	if got, err := GetReleaseAssetForRepo(database, repo.ID, asset.ID); err != nil || got.ID != asset.ID {
+		t.Errorf("GetReleaseAssetForRepo = (%+v, %v), want id %d", got, err, asset.ID)
+	}
+	if _, err := GetReleaseAssetForRepo(database, other.ID, asset.ID); !errors.Is(err, ErrNotFound) {
+		t.Errorf("cross-repo GetReleaseAssetForRepo err = %v, want ErrNotFound", err)
+	}
+	if err := DeleteReleaseAsset(database, release.ID, asset.ID); err != nil {
+		t.Fatalf("DeleteReleaseAsset: %v", err)
+	}
+	if _, err := GetReleaseAssetForRepo(database, repo.ID, asset.ID); !errors.Is(err, ErrNotFound) {
+		t.Errorf("asset after delete err = %v, want ErrNotFound", err)
+	}
+}
diff --git a/internal/db/repos.go b/internal/db/repos.go
new file mode 100644
index 0000000..d1853b8
--- /dev/null
+++ b/internal/db/repos.go
@@ -0,0 +1,125 @@
+package db
+
+import (
+	"database/sql"
+	"errors"
+	"fmt"
+)
+
+// Repo is a row in the repos table.
+type Repo struct {
+	ID            int64
+	OwnerID       int64
+	Name          string
+	Description   string
+	Visibility    string
+	DefaultBranch string
+	PushedAt      sql.NullInt64
+}
+
+// CreateRepo inserts a repo row relying on column defaults for
+// default_branch and created_at, then returns the stored row.
+func CreateRepo(database *sql.DB, ownerID int64, name, description, visibility string) (Repo, error) {
+	id, err := execLastID(database,
+		`INSERT INTO repos (owner_id, name, description, visibility) VALUES (?, ?, ?, ?)`,
+		ownerID, name, description, visibility)
+	if err != nil {
+		return Repo{}, fmt.Errorf("create repo %s: %w", name, err)
+	}
+	return GetRepoByID(database, id)
+}
+
+const repoColumns = `SELECT id, owner_id, name, description, visibility, default_branch, pushed_at FROM repos WHERE `
+
+// scanRepo reads one repos row (see repoColumns) into repo.
+func scanRepo(s rowScanner, repo *Repo) error {
+	return s.Scan(&repo.ID, &repo.OwnerID, &repo.Name, &repo.Description,
+		&repo.Visibility, &repo.DefaultBranch, &repo.PushedAt)
+}
+
+// GetRepoByID looks up a repo by primary key.
+func GetRepoByID(database *sql.DB, id int64) (Repo, error) {
+	var repo Repo
+	err := scanRepo(database.QueryRow(repoColumns+`id = ?`, id), &repo)
+	if errors.Is(err, sql.ErrNoRows) {
+		return Repo{}, fmt.Errorf("repo %d: %w", id, ErrNotFound)
+	}
+	if err != nil {
+		return Repo{}, fmt.Errorf("get repo %d: %w", id, err)
+	}
+	return repo, nil
+}
+
+// GetRepoByName looks up a repo by owner username and repo name.
+func GetRepoByName(database *sql.DB, ownerName, repoName string) (Repo, error) {
+	var repo Repo
+	err := scanRepo(database.QueryRow(
+		repoColumns+`owner_id = (SELECT id FROM users WHERE username = ?) AND name = ?`,
+		ownerName, repoName), &repo)
+	if errors.Is(err, sql.ErrNoRows) {
+		return Repo{}, fmt.Errorf("repo %s/%s: %w", ownerName, repoName, ErrNotFound)
+	}
+	if err != nil {
+		return Repo{}, fmt.Errorf("get repo %s/%s: %w", ownerName, repoName, err)
+	}
+	return repo, nil
+}
+
+// DeleteRepo removes a repo row; its bare directory on disk is the
+// caller's responsibility.
+func DeleteRepo(database *sql.DB, id int64) error {
+	return execScoped(database, fmt.Sprintf("delete repo %d", id),
+		`DELETE FROM repos WHERE id = ?`, id)
+}
+
+// UpdateRepoVisibility flips a repo between public and private.
+func UpdateRepoVisibility(database *sql.DB, id int64, visibility string) error {
+	return execScoped(database, fmt.Sprintf("update repo %d visibility", id),
+		`UPDATE repos SET visibility = ? WHERE id = ?`, visibility, id)
+}
+
+// RenameRepo changes a repo's name; the caller moves the directory on disk.
+func RenameRepo(database *sql.DB, id int64, name string) error {
+	return execScoped(database, fmt.Sprintf("rename repo %d", id),
+		`UPDATE repos SET name = ? WHERE id = ?`, name, id)
+}
+
+// RecordPush updates a repo's pushed_at and, when defaultBranch is
+// non-empty, its default_branch. It returns ErrNotFound when no row
+// matches (a push to a repo with no metadata row).
+func RecordPush(database *sql.DB, ownerName, repoName string, pushedAt int64, defaultBranch string) error {
+	query := `UPDATE repos SET pushed_at = ? WHERE owner_id = (SELECT id FROM users WHERE username = ?) AND name = ?`
+	args := []any{pushedAt, ownerName, repoName}
+	if defaultBranch != "" {
+		query = `UPDATE repos SET pushed_at = ?, default_branch = ? WHERE owner_id = (SELECT id FROM users WHERE username = ?) AND name = ?`
+		args = []any{pushedAt, defaultBranch, ownerName, repoName}
+	}
+	return execScoped(database, fmt.Sprintf("record push %s/%s", ownerName, repoName), query, args...)
+}
+
+// RepoView is a repo row plus its owner's username, for listings.
+type RepoView struct {
+	Repo
+	OwnerName string
+}
+
+// ListRepos returns public repos plus, when viewerID is non-zero, that
+// user's own repos. Ordered by owner then name.
+func ListRepos(database *sql.DB, viewerID int64) ([]RepoView, error) {
+	repos, err := listQuery(database,
+		`SELECT r.id, r.owner_id, r.name, r.description, r.visibility,
+		        r.default_branch, r.pushed_at, u.username
+		 FROM repos r JOIN users u ON u.id = r.owner_id
+		 WHERE r.visibility = 'public' OR r.owner_id = ?
+		 ORDER BY u.username, r.name`, []any{viewerID}, scanRepoView)
+	if err != nil {
+		return nil, fmt.Errorf("list repos: %w", err)
+	}
+	return repos, nil
+}
+
+// scanRepoView reads one repos+owner row (see ListRepos) into view.
+func scanRepoView(s rowScanner, view *RepoView) error {
+	return s.Scan(&view.ID, &view.OwnerID, &view.Name, &view.Description,
+		&view.Visibility, &view.DefaultBranch, &view.PushedAt, &view.OwnerName)
+}
diff --git a/internal/db/repos_test.go b/internal/db/repos_test.go
new file mode 100644
index 0000000..6c569ae
--- /dev/null
+++ b/internal/db/repos_test.go
@@ -0,0 +1,150 @@
+package db
+
+import (
+	"errors"
+	"testing"
+)
+
+func TestCreateAndGetRepo(t *testing.T) {
+	database := openTestDB(t)
+	ownerID, err := CreateUser(database, "josie", "hash")
+	if err != nil {
+		t.Fatalf("CreateUser: %v", err)
+	}
+
+	repo, err := CreateRepo(database, ownerID, "demo", "desc", "public")
+	if err != nil {
+		t.Fatalf("CreateRepo: %v", err)
+	}
+	if repo.ID == 0 || repo.OwnerID != ownerID {
+		t.Errorf("repo IDs = %+v, want nonzero id and owner %d", repo, ownerID)
+	}
+	if repo.DefaultBranch != "main" || repo.Visibility != "public" {
+		t.Errorf("repo = %+v, want column defaults main/public", repo)
+	}
+	if repo.PushedAt.Valid {
+		t.Errorf("PushedAt = %d, want NULL", repo.PushedAt.Int64)
+	}
+
+	byID, err := GetRepoByID(database, repo.ID)
+	if err != nil {
+		t.Fatalf("GetRepoByID: %v", err)
+	}
+	if byID.Name != "demo" {
+		t.Errorf("GetRepoByID name = %q, want demo", byID.Name)
+	}
+	byName, err := GetRepoByName(database, "josie", "demo")
+	if err != nil {
+		t.Fatalf("GetRepoByName: %v", err)
+	}
+	if byName.ID != repo.ID {
+		t.Errorf("GetRepoByName id = %d, want %d", byName.ID, repo.ID)
+	}
+	if _, err := GetRepoByName(database, "josie", "nope"); !errors.Is(err, ErrNotFound) {
+		t.Errorf("missing repo err = %v, want ErrNotFound", err)
+	}
+	if _, err := GetRepoByName(database, "nobody", "demo"); !errors.Is(err, ErrNotFound) {
+		t.Errorf("missing owner err = %v, want ErrNotFound", err)
+	}
+	if _, err := CreateRepo(database, ownerID, "demo", "", "private"); err == nil {
+		t.Error("duplicate (owner, name) accepted")
+	}
+}
+
+func TestUpdateRepoVisibilityAndRename(t *testing.T) {
+	database := openTestDB(t)
+	ownerID, err := CreateUser(database, "josie", "hash")
+	if err != nil {
+		t.Fatalf("CreateUser: %v", err)
+	}
+	repo, err := CreateRepo(database, ownerID, "demo", "", "private")
+	if err != nil {
+		t.Fatalf("CreateRepo: %v", err)
+	}
+
+	if err := UpdateRepoVisibility(database, repo.ID, "public"); err != nil {
+		t.Fatalf("UpdateRepoVisibility: %v", err)
+	}
+	got, err := GetRepoByID(database, repo.ID)
+	if err != nil {
+		t.Fatalf("GetRepoByID: %v", err)
+	}
+	if got.Visibility != "public" {
+		t.Errorf("Visibility = %q, want public", got.Visibility)
+	}
+
+	if err := RenameRepo(database, repo.ID, "renamed"); err != nil {
+		t.Fatalf("RenameRepo: %v", err)
+	}
+	if _, err := GetRepoByName(database, "josie", "renamed"); err != nil {
+		t.Errorf("renamed repo not found: %v", err)
+	}
+	if _, err := GetRepoByName(database, "josie", "demo"); !errors.Is(err, ErrNotFound) {
+		t.Errorf("old name still resolves: %v", err)
+	}
+
+	if err := UpdateRepoVisibility(database, repo.ID+99, "public"); !errors.Is(err, ErrNotFound) {
+		t.Errorf("visibility on missing repo err = %v, want ErrNotFound", err)
+	}
+	if err := RenameRepo(database, repo.ID+99, "x"); !errors.Is(err, ErrNotFound) {
+		t.Errorf("rename missing repo err = %v, want ErrNotFound", err)
+	}
+}
+
+func TestRecordPush(t *testing.T) {
+	database := openTestDB(t)
+	ownerID, err := CreateUser(database, "josie", "hash")
+	if err != nil {
+		t.Fatalf("CreateUser: %v", err)
+	}
+	if _, err := CreateRepo(database, ownerID, "demo", "", "private"); err != nil {
+		t.Fatalf("CreateRepo: %v", err)
+	}
+
+	if err := RecordPush(database, "josie", "demo", 12345, ""); err != nil {
+		t.Fatalf("RecordPush touch: %v", err)
+	}
+	repo, err := GetRepoByName(database, "josie", "demo")
+	if err != nil {
+		t.Fatalf("GetRepoByName: %v", err)
+	}
+	if !repo.PushedAt.Valid || repo.PushedAt.Int64 != 12345 {
+		t.Errorf("PushedAt = %+v, want 12345", repo.PushedAt)
+	}
+	if repo.DefaultBranch != "main" {
+		t.Errorf("DefaultBranch = %q, want unchanged main", repo.DefaultBranch)
+	}
+
+	if err := RecordPush(database, "josie", "demo", 12346, "master"); err != nil {
+		t.Fatalf("RecordPush default: %v", err)
+	}
+	repo, err = GetRepoByName(database, "josie", "demo")
+	if err != nil {
+		t.Fatalf("GetRepoByName: %v", err)
+	}
+	if repo.DefaultBranch != "master" || repo.PushedAt.Int64 != 12346 {
+		t.Errorf("repo = %+v, want default master and pushed_at 12346", repo)
+	}
+
+	if err := RecordPush(database, "josie", "nope", 1, ""); !errors.Is(err, ErrNotFound) {
+		t.Errorf("RecordPush missing repo err = %v, want ErrNotFound", err)
+	}
+}
+
+func TestDeleteRepo(t *testing.T) {
+	database := openTestDB(t)
+	ownerID, err := CreateUser(database, "josie", "hash")
+	if err != nil {
+		t.Fatalf("CreateUser: %v", err)
+	}
+	repo, err := CreateRepo(database, ownerID, "demo", "", "private")
+	if err != nil {
+		t.Fatalf("CreateRepo: %v", err)
+	}
+	if err := DeleteRepo(database, repo.ID); err != nil {
+		t.Fatalf("DeleteRepo: %v", err)
+	}
+	if _, err := GetRepoByID(database, repo.ID); !errors.Is(err, ErrNotFound) {
+		t.Errorf("deleted repo err = %v, want ErrNotFound", err)
+	}
+}
diff --git a/internal/db/sessions.go b/internal/db/sessions.go
new file mode 100644
index 0000000..d5f9c9e
--- /dev/null
+++ b/internal/db/sessions.go
@@ -0,0 +1,73 @@
+package db
+
+import (
+	"database/sql"
+	"errors"
+	"fmt"
+)
+
+// Session is a row in the sessions table.
+type Session struct {
+	Token     string
+	UserID    int64
+	ExpiresAt int64
+}
+
+// CreateSession stores a new login session for user userID.
+func CreateSession(database *sql.DB, token string, userID, expiresAt int64) error {
+	_, err := database.Exec(
+		`INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)`,
+		token, userID, expiresAt,
+	)
+	if err != nil {
+		return fmt.Errorf("create session: %w", err)
+	}
+	return nil
+}
+
+// GetSession looks up a session by token, returning ErrNotFound for
+// unknown or expired tokens alike.
+func GetSession(database *sql.DB, token string) (Session, error) {
+	var session Session
+	err := database.QueryRow(
+		`SELECT token, user_id, expires_at FROM sessions
+		 WHERE token = ? AND expires_at > unixepoch()`,
+		token,
+	).Scan(&session.Token, &session.UserID, &session.ExpiresAt)
+	if errors.Is(err, sql.ErrNoRows) {
+		return Session{}, fmt.Errorf("session: %w", ErrNotFound)
+	}
+	if err != nil {
+		return Session{}, fmt.Errorf("get session: %w", err)
+	}
+	return session, nil
+}
+
+// DeleteSession removes a session at logout.
+func DeleteSession(database *sql.DB, token string) error {
+	_, err := database.Exec(`DELETE FROM sessions WHERE token = ?`, token)
+	if err != nil {
+		return fmt.Errorf("delete session: %w", err)
+	}
+	return nil
+}
+
+// DeleteOtherSessions revokes every session for a user except keepToken,
+// so a password change signs other browsers out.
+func DeleteOtherSessions(database *sql.DB, userID int64, keepToken string) error {
+	_, err := database.Exec(
+		`DELETE FROM sessions WHERE user_id = ? AND token != ?`, userID, keepToken)
+	if err != nil {
+		return fmt.Errorf("delete other sessions for user %d: %w", userID, err)
+	}
+	return nil
+}
+
+// DeleteExpiredSessions purges sessions whose expiry has passed; callers
+// use it as opportunistic housekeeping, so no error on zero rows.
+func DeleteExpiredSessions(database *sql.DB) error {
+	if _, err := database.Exec(`DELETE FROM sessions WHERE expires_at <= unixepoch()`); err != nil {
+		return fmt.Errorf("delete expired sessions: %w", err)
+	}
+	return nil
+}
diff --git a/internal/db/tokens.go b/internal/db/tokens.go
new file mode 100644
index 0000000..49a69c9
--- /dev/null
+++ b/internal/db/tokens.go
@@ -0,0 +1,77 @@
+package db
+
+import (
+	"database/sql"
+	"errors"
+	"fmt"
+)
+
+// Token is a row in the tokens table. The plaintext is never stored.
+type Token struct {
+	ID         int64
+	UserID     int64
+	Name       string
+	Hint       string
+	CreatedAt  int64
+	LastUsedAt sql.NullInt64
+}
+
+const tokenColumns = `SELECT id, user_id, name, hint, created_at, last_used_at FROM tokens WHERE `
+
+// CreateToken stores a git token (tokenHash is a digest; hint a display
+// prefix) and returns the stored row.
+func CreateToken(database *sql.DB, userID int64, name, hint, tokenHash string) (Token, error) {
+	id, err := execLastID(database,
+		`INSERT INTO tokens (user_id, name, hint, token_hash) VALUES (?, ?, ?, ?)`,
+		userID, name, hint, tokenHash)
+	if err != nil {
+		return Token{}, fmt.Errorf("create token: %w", err)
+	}
+	return getToken(database, tokenColumns+`id = ?`, id)
+}
+
+// GetTokenByHash looks up a token by its digest; ErrNotFound when absent.
+func GetTokenByHash(database *sql.DB, tokenHash string) (Token, error) {
+	return getToken(database, tokenColumns+`token_hash = ?`, tokenHash)
+}
+
+// scanToken reads one tokens row (see tokenColumns) into tok.
+func scanToken(s rowScanner, tok *Token) error {
+	return s.Scan(&tok.ID, &tok.UserID, &tok.Name, &tok.Hint, &tok.CreatedAt, &tok.LastUsedAt)
+}
+
+func getToken(database *sql.DB, query string, arg any) (Token, error) {
+	var tok Token
+	err := scanToken(database.QueryRow(query, arg), &tok)
+	if errors.Is(err, sql.ErrNoRows) {
+		return Token{}, fmt.Errorf("token: %w", ErrNotFound)
+	}
+	if err != nil {
+		return Token{}, fmt.Errorf("get token: %w", err)
+	}
+	return tok, nil
+}
+
+// ListTokens returns a user's tokens, newest first.
+func ListTokens(database *sql.DB, userID int64) ([]Token, error) {
+	tokens, err := listQuery(database,
+		tokenColumns+`user_id = ? ORDER BY created_at DESC, id DESC`, []any{userID}, scanToken)
+	if err != nil {
+		return nil, fmt.Errorf("list tokens: %w", err)
+	}
+	return tokens, nil
+}
+
+// TouchToken records that a token was just used.
+func TouchToken(database *sql.DB, id int64) error {
+	if _, err := database.Exec(`UPDATE tokens SET last_used_at = unixepoch() WHERE id = ?`, id); err != nil {
+		return fmt.Errorf("touch token %d: %w", id, err)
+	}
+	return nil
+}
+
+// DeleteToken revokes a token, scoped to its owner.
+func DeleteToken(database *sql.DB, userID, id int64) error {
+	return execScoped(database, fmt.Sprintf("delete token %d", id),
+		`DELETE FROM tokens WHERE id = ? AND user_id = ?`, id, userID)
+}
diff --git a/internal/db/tokens_test.go b/internal/db/tokens_test.go
new file mode 100644
index 0000000..6c7c16b
--- /dev/null
+++ b/internal/db/tokens_test.go
@@ -0,0 +1,86 @@
+package db
+
+import (
+	"errors"
+	"testing"
+)
+
+func TestTokenLifecycle(t *testing.T) {
+	database := openTestDB(t)
+	userID, err := CreateUser(database, "josie", "hash")
+	if err != nil {
+		t.Fatalf("CreateUser: %v", err)
+	}
+
+	tok, err := CreateToken(database, userID, "laptop", "sg_abcd123", "hash-1")
+	if err != nil {
+		t.Fatalf("CreateToken: %v", err)
+	}
+	if tok.ID == 0 || tok.UserID != userID || tok.Name != "laptop" || tok.Hint != "sg_abcd123" {
+		t.Errorf("token = %+v, want populated row", tok)
+	}
+	if tok.LastUsedAt.Valid {
+		t.Errorf("LastUsedAt = %+v, want NULL", tok.LastUsedAt)
+	}
+
+	byHash, err := GetTokenByHash(database, "hash-1")
+	if err != nil {
+		t.Fatalf("GetTokenByHash: %v", err)
+	}
+	if byHash.ID != tok.ID {
+		t.Errorf("GetTokenByHash id = %d, want %d", byHash.ID, tok.ID)
+	}
+	if _, err := GetTokenByHash(database, "nope"); !errors.Is(err, ErrNotFound) {
+		t.Errorf("missing hash err = %v, want ErrNotFound", err)
+	}
+
+	if err := TouchToken(database, tok.ID); err != nil {
+		t.Fatalf("TouchToken: %v", err)
+	}
+	byHash, err = GetTokenByHash(database, "hash-1")
+	if err != nil {
+		t.Fatalf("GetTokenByHash after touch: %v", err)
+	}
+	if !byHash.LastUsedAt.Valid {
+		t.Error("LastUsedAt still NULL after TouchToken")
+	}
+
+	if _, err := CreateToken(database, userID, "dup", "sg_x", "hash-1"); err == nil {
+		t.Error("duplicate token_hash accepted, want UNIQUE error")
+	}
+
+	tokens, err := ListTokens(database, userID)
+	if err != nil {
+		t.Fatalf("ListTokens: %v", err)
+	}
+	if len(tokens) != 1 || tokens[0].ID != tok.ID {
+		t.Errorf("ListTokens = %+v, want the one token", tokens)
+	}
+
+	if err := DeleteToken(database, userID+1, tok.ID); !errors.Is(err, ErrNotFound) {
+		t.Errorf("DeleteToken by non-owner err = %v, want ErrNotFound", err)
+	}
+	if err := DeleteToken(database, userID, tok.ID); err != nil {
+		t.Fatalf("DeleteToken: %v", err)
+	}
+	if _, err := GetTokenByHash(database, "hash-1"); !errors.Is(err, ErrNotFound) {
+		t.Errorf("token still present after delete: %v", err)
+	}
+}
+
+func TestTokenCascadeOnUserDelete(t *testing.T) {
+	database := openTestDB(t)
+	userID, err := CreateUser(database, "josie", "hash")
+	if err != nil {
+		t.Fatalf("CreateUser: %v", err)
+	}
+	if _, err := CreateToken(database, userID, "laptop", "sg_abcd123", "hash-1"); err != nil {
+		t.Fatalf("CreateToken: %v", err)
+	}
+	if _, err := database.Exec(`DELETE FROM users WHERE id = ?`, userID); err != nil {
+		t.Fatalf("delete user: %v", err)
+	}
+	if _, err := GetTokenByHash(database, "hash-1"); !errors.Is(err, ErrNotFound) {
+		t.Errorf("token survived user delete: %v", err)
+	}
+}
diff --git a/internal/db/users.go b/internal/db/users.go
new file mode 100644
index 0000000..4dc9964
--- /dev/null
+++ b/internal/db/users.go
@@ -0,0 +1,66 @@
+package db
+
+import (
+	"database/sql"
+	"errors"
+	"fmt"
+)
+
+// ErrNotFound is returned when a lookup by key matches no row.
+var ErrNotFound = errors.New("not found")
+
+// User is a row in the users table.
+type User struct {
+	ID           int64
+	Username     string
+	PasswordHash string
+}
+
+// CreateUser inserts a user and returns its id.
+func CreateUser(database *sql.DB, username, passwordHash string) (int64, error) {
+	id, err := execLastID(database,
+		`INSERT INTO users (username, password_hash) VALUES (?, ?)`, username, passwordHash)
+	if err != nil {
+		return 0, fmt.Errorf("create user %s: %w", username, err)
+	}
+	return id, nil
+}
+
+// scanUser reads one users row into user.
+func scanUser(s rowScanner, user *User) error {
+	return s.Scan(&user.ID, &user.Username, &user.PasswordHash)
+}
+
+// GetUserByName looks up a user by their unique username.
+func GetUserByName(database *sql.DB, username string) (User, error) {
+	var user User
+	err := scanUser(database.QueryRow(
+		`SELECT id, username, password_hash FROM users WHERE username = ?`, username), &user)
+	if errors.Is(err, sql.ErrNoRows) {
+		return User{}, fmt.Errorf("user %s: %w", username, ErrNotFound)
+	}
+	if err != nil {
+		return User{}, fmt.Errorf("get user %s: %w", username, err)
+	}
+	return user, nil
+}
+
+// GetUserByID looks up a user by primary key.
+func GetUserByID(database *sql.DB, id int64) (User, error) {
+	var user User
+	err := scanUser(database.QueryRow(
+		`SELECT id, username, password_hash FROM users WHERE id = ?`, id), &user)
+	if errors.Is(err, sql.ErrNoRows) {
+		return User{}, fmt.Errorf("user %d: %w", id, ErrNotFound)
+	}
+	if err != nil {
+		return User{}, fmt.Errorf("get user %d: %w", id, err)
+	}
+	return user, nil
+}
+
+// UpdateUserPassword replaces a user's password hash.
+func UpdateUserPassword(database *sql.DB, id int64, passwordHash string) error {
+	return execScoped(database, fmt.Sprintf("update password for user %d", id),
+		`UPDATE users SET password_hash = ? WHERE id = ?`, passwordHash, id)
+}
diff --git a/internal/db/users_sessions_test.go b/internal/db/users_sessions_test.go
new file mode 100644
index 0000000..c95f119
--- /dev/null
+++ b/internal/db/users_sessions_test.go
@@ -0,0 +1,161 @@
+package db
+
+import (
+	"database/sql"
+	"errors"
+	"path/filepath"
+	"testing"
+	"time"
+)
+
+func openTestDB(t *testing.T) *sql.DB {
+	t.Helper()
+	database, err := Open(filepath.Join(t.TempDir(), "test.db"))
+	if err != nil {
+		t.Fatalf("Open: %v", err)
+	}
+	t.Cleanup(func() { database.Close() })
+	return database
+}
+
+func TestGetUserByName(t *testing.T) {
+	database := openTestDB(t)
+	id, err := CreateUser(database, "josie", "hash")
+	if err != nil {
+		t.Fatalf("CreateUser: %v", err)
+	}
+
+	user, err := GetUserByName(database, "josie")
+	if err != nil {
+		t.Fatalf("GetUserByName: %v", err)
+	}
+	if user.ID != id || user.PasswordHash != "hash" {
+		t.Errorf("user = %+v, want id %d and stored hash", user, id)
+	}
+	if _, err := GetUserByName(database, "nobody"); !errors.Is(err, ErrNotFound) {
+		t.Errorf("missing user err = %v, want ErrNotFound", err)
+	}
+	if _, err := CreateUser(database, "josie", "other"); err == nil {
+		t.Error("duplicate username accepted")
+	}
+}
+
+func TestGetUserByID(t *testing.T) {
+	database := openTestDB(t)
+	id, err := CreateUser(database, "josie", "hash")
+	if err != nil {
+		t.Fatalf("CreateUser: %v", err)
+	}
+	user, err := GetUserByID(database, id)
+	if err != nil {
+		t.Fatalf("GetUserByID: %v", err)
+	}
+	if user.Username != "josie" {
+		t.Errorf("username = %q, want josie", user.Username)
+	}
+	if _, err := GetUserByID(database, 999); !errors.Is(err, ErrNotFound) {
+		t.Errorf("missing user err = %v, want ErrNotFound", err)
+	}
+}
+
+func TestSessionLifecycle(t *testing.T) {
+	database := openTestDB(t)
+	id, err := CreateUser(database, "josie", "hash")
+	if err != nil {
+		t.Fatalf("CreateUser: %v", err)
+	}
+
+	if err := CreateSession(database, "tok", id, time.Now().Add(time.Hour).Unix()); err != nil {
+		t.Fatalf("CreateSession: %v", err)
+	}
+	session, err := GetSession(database, "tok")
+	if err != nil {
+		t.Fatalf("GetSession: %v", err)
+	}
+	if session.UserID != id {
+		t.Errorf("session.UserID = %d, want %d", session.UserID, id)
+	}
+
+	if err := DeleteSession(database, "tok"); err != nil {
+		t.Fatalf("DeleteSession: %v", err)
+	}
+	if _, err := GetSession(database, "tok"); !errors.Is(err, ErrNotFound) {
+		t.Errorf("deleted session err = %v, want ErrNotFound", err)
+	}
+}
+
+func TestExpiredSessionNotReturned(t *testing.T) {
+	database := openTestDB(t)
+	id, err := CreateUser(database, "josie", "hash")
+	if err != nil {
+		t.Fatalf("CreateUser: %v", err)
+	}
+	if err := CreateSession(database, "old", id, time.Now().Add(-time.Hour).Unix()); err != nil {
+		t.Fatalf("CreateSession: %v", err)
+	}
+	if _, err := GetSession(database, "old"); !errors.Is(err, ErrNotFound) {
+		t.Errorf("expired session err = %v, want ErrNotFound", err)
+	}
+}
+
+func TestSessionsCascadeWithUser(t *testing.T) {
+	database := openTestDB(t)
+	id, err := CreateUser(database, "josie", "hash")
+	if err != nil {
+		t.Fatalf("CreateUser: %v", err)
+	}
+	if err := CreateSession(database, "tok", id, time.Now().Add(time.Hour).Unix()); err != nil {
+		t.Fatalf("CreateSession: %v", err)
+	}
+	if _, err := database.Exec(`DELETE FROM users WHERE id = ?`, id); err != nil {
+		t.Fatalf("delete user: %v", err)
+	}
+	if _, err := GetSession(database, "tok"); !errors.Is(err, ErrNotFound) {
+		t.Errorf("orphaned session err = %v, want ErrNotFound", err)
+	}
+}
+
+func TestUpdateUserPassword(t *testing.T) {
+	database := openTestDB(t)
+	id, err := CreateUser(database, "josie", "old-hash")
+	if err != nil {
+		t.Fatalf("CreateUser: %v", err)
+	}
+	if err := UpdateUserPassword(database, id, "new-hash"); err != nil {
+		t.Fatalf("UpdateUserPassword: %v", err)
+	}
+	user, err := GetUserByID(database, id)
+	if err != nil {
+		t.Fatalf("GetUserByID: %v", err)
+	}
+	if user.PasswordHash != "new-hash" {
+		t.Errorf("PasswordHash = %q, want new-hash", user.PasswordHash)
+	}
+	if err := UpdateUserPassword(database, id+1, "x"); !errors.Is(err, ErrNotFound) {
+		t.Errorf("missing user err = %v, want ErrNotFound", err)
+	}
+}
+
+func TestDeleteOtherSessions(t *testing.T) {
+	database := openTestDB(t)
+	id, err := CreateUser(database, "josie", "hash")
+	if err != nil {
+		t.Fatalf("CreateUser: %v", err)
+	}
+	for _, token := range []string{"keep", "drop-1", "drop-2"} {
+		if err := CreateSession(database, token, id, 9999999999); err != nil {
+			t.Fatalf("CreateSession %s: %v", token, err)
+		}
+	}
+	if err := DeleteOtherSessions(database, id, "keep"); err != nil {
+		t.Fatalf("DeleteOtherSessions: %v", err)
+	}
+	if _, err := GetSession(database, "keep"); err != nil {
+		t.Errorf("kept session gone: %v", err)
+	}
+	for _, token := range []string{"drop-1", "drop-2"} {
+		if _, err := GetSession(database, token); !errors.Is(err, ErrNotFound) {
+			t.Errorf("session %s err = %v, want ErrNotFound", token, err)
+		}
+	}
+}
diff --git a/internal/git/backend.go b/internal/git/backend.go
new file mode 100644
index 0000000..2fcf6dd
--- /dev/null
+++ b/internal/git/backend.go
@@ -0,0 +1,152 @@
+package git
+
+import (
+	"bufio"
+	"bytes"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"os"
+	"os/exec"
+	"strconv"
+	"strings"
+)
+
+// ServeBackend execs `git http-backend` as a CGI for req, translating it
+// into the environment http-backend expects: GIT_PROJECT_ROOT is
+// projectRoot, PATH_INFO is the request path, and remoteUser (when the
+// caller has authenticated someone) is passed as REMOTE_USER. service is
+// the single git service the caller has authorized; it becomes
+// QUERY_STRING, so the raw request query cannot smuggle in a second
+// service that re-routes the CGI. The CGI response — including any Status
+// header — is written to w. The returned error only reports exec/pipe
+// failures; a 404 from http-backend is a successful response.
+func ServeBackend(projectRoot, remoteUser, service string, req *http.Request, w http.ResponseWriter) error {
+	query := ""
+	if service != "" {
+		query = "service=" + url.QueryEscape(service)
+	}
+	env := []string{
+		"GIT_PROJECT_ROOT=" + projectRoot,
+		"GIT_HTTP_EXPORT_ALL=1",
+		"GATEWAY_INTERFACE=CGI/1.1",
+		"SERVER_PROTOCOL=" + req.Proto,
+		"REQUEST_METHOD=" + req.Method,
+		"PATH_INFO=" + req.URL.Path,
+		"QUERY_STRING=" + query,
+		"CONTENT_TYPE=" + req.Header.Get("Content-Type"),
+		"CONTENT_LENGTH=" + req.Header.Get("Content-Length"),
+		"LANG=C",
+		"LC_ALL=C",
+		"PATH=" + os.Getenv("PATH"),
+	}
+	if remoteUser != "" {
+		env = append(env, "REMOTE_USER="+remoteUser)
+	}
+	// The post-receive hook needs to find this binary; ServeBackend is the
+	// only caller that runs receive-pack, so it passes the path explicitly
+	// rather than letting the hook depend on the ambient environment.
+	if self, err := os.Executable(); err == nil {
+		env = append(env, "SIMPLEGIT_BIN="+self)
+	}
+
+	cmd := exec.Command("git", "http-backend")
+	cmd.Env = env
+	var stderr bytes.Buffer
+	cmd.Stderr = &stderr
+
+	// The request body is streamed into http-backend's stdin through an
+	// explicit pipe so we can wait for the copy to finish before writing the
+	// response. net/http starts a background reader for the request body as
+	// soon as the handler writes a response; that reader would race this copy
+	// and truncate large pushes (git's receive-pack then waits forever for
+	// the missing pack bytes).
+	var stdinDone chan error
+	if req.Method != http.MethodGet && req.Body != nil {
+		pr, pw, err := os.Pipe()
+		if err != nil {
+			return fmt.Errorf("http-backend stdin pipe: %w", err)
+		}
+		cmd.Stdin = pr
+		stdinDone = make(chan error, 1)
+		go func() {
+			_, copyErr := io.Copy(pw, req.Body)
+			pw.Close()
+			stdinDone <- copyErr
+		}()
+		defer pr.Close()
+	}
+	stdout, err := cmd.StdoutPipe()
+	if err != nil {
+		return fmt.Errorf("http-backend stdout pipe: %w", err)
+	}
+	if err := cmd.Start(); err != nil {
+		return fmt.Errorf("start http-backend: %w", err)
+	}
+	if stdinDone != nil {
+		if err := <-stdinDone; err != nil {
+			cmd.Wait()
+			return fmt.Errorf("feed http-backend stdin: %w", err)
+		}
+	}
+
+	br := bufio.NewReader(stdout)
+	status, headers, err := readCGIHeaders(br)
+	if err != nil {
+		cmd.Wait()
+		return fmt.Errorf("read http-backend headers: %w: %s", err, strings.TrimSpace(stderr.String()))
+	}
+	for _, header := range headers {
+		if !strings.EqualFold(header[0], "Transfer-Encoding") {
+			w.Header().Add(header[0], header[1])
+		}
+	}
+	w.WriteHeader(status)
+	if flusher, ok := w.(http.Flusher); ok {
+		flusher.Flush()
+	}
+	if _, err := io.Copy(w, br); err != nil {
+		cmd.Wait()
+		return fmt.Errorf("stream http-backend body: %w", err)
+	}
+	if err := cmd.Wait(); err != nil {
+		return fmt.Errorf("http-backend: %w: %s", err, strings.TrimSpace(stderr.String()))
+	}
+	return nil
+}
+
+// readCGIHeaders parses the CGI header block, pulling the optional Status
+// header out as the HTTP code; everything else is returned verbatim.
+func readCGIHeaders(r *bufio.Reader) (int, [][2]string, error) {
+	status := http.StatusOK
+	var headers [][2]string
+	for {
+		line, err := r.ReadString('\n')
+		line = strings.TrimRight(line, "\r\n")
+		if line == "" {
+			if err != nil {
+				return 0, nil, fmt.Errorf("eof before end of headers: %w", err)
+			}
+			return status, headers, nil
+		}
+		key, value, ok := strings.Cut(line, ":")
+		if !ok {
+			return 0, nil, fmt.Errorf("malformed header line %q", line)
+		}
+		value = strings.TrimSpace(value)
+		if strings.EqualFold(key, "Status") {
+			code, _, _ := strings.Cut(value, " ")
+			parsed, err := strconv.Atoi(code)
+			if err != nil {
+				return 0, nil, fmt.Errorf("bad Status header %q", value)
+			}
+			status = parsed
+			continue
+		}
+		headers = append(headers, [2]string{key, value})
+		if err != nil {
+			return 0, nil, fmt.Errorf("eof mid-headers: %w", err)
+		}
+	}
+}
diff --git a/internal/git/backend_test.go b/internal/git/backend_test.go
new file mode 100644
index 0000000..55246ef
--- /dev/null
+++ b/internal/git/backend_test.go
@@ -0,0 +1,51 @@
+package git
+
+import (
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func TestServeBackendAdvertisement(t *testing.T) {
+	root := t.TempDir()
+	repoPath := filepath.Join(root, "josie", "demo.git")
+	if err := InitBare(repoPath, "main"); err != nil {
+		t.Fatalf("InitBare: %v", err)
+	}
+
+	req := httptest.NewRequest("GET", "/josie/demo.git/info/refs?service=git-upload-pack", nil)
+	rec := httptest.NewRecorder()
+	if err := ServeBackend(root, "", "git-upload-pack", req, rec); err != nil {
+		t.Fatalf("ServeBackend: %v", err)
+	}
+	if rec.Code != 200 {
+		t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body)
+	}
+	if ct := rec.Header().Get("Content-Type"); ct != "application/x-git-upload-pack-advertisement" {
+		t.Errorf("Content-Type = %q, want upload-pack advertisement", ct)
+	}
+	body := rec.Body.String()
+	if !strings.HasPrefix(body, "001e# service=git-upload-pack\n0000") {
+		t.Errorf("body lacks smart-HTTP service header, starts: %q", body)
+	}
+	if !strings.Contains(body, "0000000000000000000000000000000000000000 capabilities^{}") {
+		t.Errorf("body lacks empty-repo zero-oid advertisement: %q", body)
+	}
+}
+
+func TestServeBackendUnknownRepo(t *testing.T) {
+	root := t.TempDir()
+	if err := os.MkdirAll(filepath.Join(root, "josie"), 0o755); err != nil {
+		t.Fatalf("mkdir: %v", err)
+	}
+	req := httptest.NewRequest("GET", "/josie/missing.git/info/refs?service=git-upload-pack", nil)
+	rec := httptest.NewRecorder()
+	if err := ServeBackend(root, "", "git-upload-pack", req, rec); err != nil {
+		t.Fatalf("ServeBackend: %v", err)
+	}
+	if rec.Code != 404 {
+		t.Errorf("status = %d, want 404 (body %q)", rec.Code, rec.Body)
+	}
+}
diff --git a/internal/git/browse.go b/internal/git/browse.go
new file mode 100644
index 0000000..b4a62c9
--- /dev/null
+++ b/internal/git/browse.go
@@ -0,0 +1,325 @@
+package git
+
+import (
+	"bufio"
+	"bytes"
+	"errors"
+	"fmt"
+	"io"
+	"os/exec"
+	"strconv"
+	"strings"
+)
+
+// TreeEntry is one item in a tree listing at a ref.
+type TreeEntry struct {
+	Mode string
+	Type string // "blob", "tree", or "commit" for a submodule
+	OID  string
+	Path string // relative to the repo root
+}
+
+// LsTree lists one directory level of ref. dir is "" for the root;
+// entry paths come back relative to the repo root.
+func LsTree(repoPath, ref, dir string) ([]TreeEntry, error) {
+	args := []string{"ls-tree", "-z", ref}
+	if dir != "" {
+		args = append(args, "--", dir+"/")
+	}
+	cmd := gitCommand(repoPath, args...)
+	var stderr bytes.Buffer
+	cmd.Stderr = &stderr
+	out, err := cmd.Output()
+	if err != nil {
+		return nil, fmt.Errorf("git ls-tree %v in %s: %w: %s", args, repoPath, err, strings.TrimSpace(stderr.String()))
+	}
+
+	var entries []TreeEntry
+	for _, line := range bytes.Split(out, []byte{0}) {
+		if len(line) == 0 {
+			continue
+		}
+		// "<mode> <type> <oid>\t<path>"
+		meta, path, ok := bytes.Cut(line, []byte{'\t'})
+		if !ok {
+			return nil, fmt.Errorf("git ls-tree: malformed entry %q", line)
+		}
+		fields := strings.Fields(string(meta))
+		if len(fields) != 3 {
+			return nil, fmt.Errorf("git ls-tree: malformed meta %q", meta)
+		}
+		entries = append(entries, TreeEntry{
+			Mode: fields[0], Type: fields[1], OID: fields[2], Path: string(path),
+		})
+	}
+	return entries, nil
+}
+
+// LsTreePaths lists every file path in ref's tree, recursively, in git's
+// tree order. Directories are implied by path segments and not listed.
+func LsTreePaths(repoPath, ref string) ([]string, error) {
+	cmd := gitCommand(repoPath, "ls-tree", "-r", "-z", "--name-only", ref)
+	var stderr bytes.Buffer
+	cmd.Stderr = &stderr
+	out, err := cmd.Output()
+	if err != nil {
+		return nil, fmt.Errorf("git ls-tree -r %s in %s: %w: %s", ref, repoPath, err, strings.TrimSpace(stderr.String()))
+	}
+	var paths []string
+	for _, p := range bytes.Split(out, []byte{0}) {
+		if len(p) > 0 {
+			paths = append(paths, string(p))
+		}
+	}
+	return paths, nil
+}
+
+// ShowFile returns the contents of path at ref, reading at most limit+1
+// bytes; callers detect oversized content with len(source) > limit.
+func ShowFile(repoPath, ref, path string, limit int) ([]byte, error) {
+	cmd := gitCommand(repoPath, "show", ref+":"+path)
+	var stderr bytes.Buffer
+	cmd.Stderr = &stderr
+	out, oversized, err := runBounded(cmd, limit)
+	if oversized {
+		return out, nil
+	}
+	if err != nil {
+		var ee *exec.ExitError
+		if errors.As(err, &ee) && ee.ExitCode() == 128 {
+			return nil, fmt.Errorf("git show %s:%s: %w: %s", ref, path, ErrNotFound, strings.TrimSpace(stderr.String()))
+		}
+		return nil, fmt.Errorf("git show %s:%s: %w: %s", ref, path, err, strings.TrimSpace(stderr.String()))
+	}
+	return out, nil
+}
+
+var ErrNotFound = errors.New("not found")
+
+// RefExists reports whether ref resolves in the repository (an empty
+// bare repo resolves nothing).
+func RefExists(repoPath, ref string) (bool, error) {
+	cmd := gitCommand(repoPath, "rev-parse", "--verify", "--quiet", ref)
+	if err := cmd.Run(); err != nil {
+		var ee *exec.ExitError
+		if errors.As(err, &ee) && ee.ExitCode() == 1 {
+			return false, nil
+		}
+		return false, fmt.Errorf("git rev-parse %s in %s: %w", ref, repoPath, err)
+	}
+	return true, nil
+}
+
+// RefNames lists the short names of every branch and tag, so callers can
+// test ref membership without one subprocess per candidate.
+func RefNames(repoPath string) ([]string, error) {
+	return forEachRefNames(repoPath, "refs/heads", "refs/tags")
+}
+
+// forEachRefNames lists %(refname:short) for each matching pattern.
+func forEachRefNames(repoPath string, patterns ...string) ([]string, error) {
+	args := append([]string{"for-each-ref", "--format=%(refname:short)"}, patterns...)
+	cmd := gitCommand(repoPath, args...)
+	var stderr bytes.Buffer
+	cmd.Stderr = &stderr
+	out, err := cmd.Output()
+	if err != nil {
+		return nil, fmt.Errorf("git for-each-ref in %s: %w: %s", repoPath, err, strings.TrimSpace(stderr.String()))
+	}
+	var names []string
+	for _, line := range strings.Split(string(out), "\n") {
+		if name := strings.TrimSpace(line); name != "" {
+			names = append(names, name)
+		}
+	}
+	return names, nil
+}
+
+// CatFileInfo inspects an object (typically "ref:path") in a single
+// batched cat-file read: it reports the object type, its size, whether the
+// first max bytes contain a NUL (the same binary heuristic as the blob
+// view), and whether the object exceeds max. At most max+1 bytes are read
+// whatever the object's size; unknown objects return ErrNotFound.
+func CatFileInfo(repoPath, object string, max int) (objectType string, size int, isBinary, truncated bool, err error) {
+	if object == "" || strings.HasPrefix(object, "-") {
+		return "", 0, false, false, fmt.Errorf("git cat-file: invalid object %q", object)
+	}
+	cmd := gitCommand(repoPath, "cat-file", "--batch", "--buffer")
+	stdin, err := cmd.StdinPipe()
+	if err != nil {
+		return "", 0, false, false, fmt.Errorf("cat-file stdin: %w", err)
+	}
+	stdout, err := cmd.StdoutPipe()
+	if err != nil {
+		return "", 0, false, false, fmt.Errorf("cat-file stdout: %w", err)
+	}
+	if err := cmd.Start(); err != nil {
+		return "", 0, false, false, fmt.Errorf("start cat-file: %w", err)
+	}
+	// done stops the (read-only) cat-file in every path; Wait reaps it.
+	done := func() {
+		stdout.Close()
+		_ = cmd.Process.Kill()
+		_ = cmd.Wait()
+	}
+
+	if _, err := io.WriteString(stdin, object+"\n"); err != nil {
+		done()
+		return "", 0, false, false, fmt.Errorf("write cat-file request: %w", err)
+	}
+	stdin.Close()
+
+	line, err := bufio.NewReader(stdout).ReadString('\n')
+	if err != nil {
+		done()
+		return "", 0, false, false, fmt.Errorf("read cat-file header: %w", err)
+	}
+	fields := strings.Fields(line)
+	if len(fields) < 2 || fields[1] == "missing" {
+		done()
+		return "", 0, false, false, fmt.Errorf("git cat-file %s: %w", object, ErrNotFound)
+	}
+	if len(fields) < 3 {
+		done()
+		return "", 0, false, false, fmt.Errorf("git cat-file %s: malformed header %q", object, line)
+	}
+	size, err = strconv.Atoi(fields[2])
+	if err != nil {
+		done()
+		return "", 0, false, false, fmt.Errorf("git cat-file %s: bad size %q: %w", object, fields[2], err)
+	}
+	objectType = fields[1]
+
+	if objectType == "blob" {
+		br := bufio.NewReader(stdout)
+		buf := make([]byte, max+1)
+		n, err := io.ReadFull(br, buf)
+		if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
+			done()
+			return "", 0, false, false, fmt.Errorf("read cat-file body: %w", err)
+		}
+		truncated = n > max
+		for _, b := range buf[:min(n, max)] {
+			if b == 0 {
+				isBinary = true
+				break
+			}
+		}
+	}
+	done()
+	return objectType, size, isBinary, truncated, nil
+}
+
+// Commit is the metadata of one revision.
+type Commit struct {
+	SHA     string
+	Author  string
+	Email   string
+	Date    string // author date, ISO 8601
+	Subject string
+	Body    string
+	Parents []string
+}
+
+const (
+	logFormat = "%x1e%H%x1f%an%x1f%ae%x1f%aI%x1f%s%x1f%b"
+	recordSep = "\x1e"
+	fieldSep  = "\x1f"
+)
+
+func parseLog(out []byte) []Commit {
+	var commits []Commit
+	for _, rec := range strings.Split(string(out), recordSep) {
+		rec = strings.TrimPrefix(rec, "\n")
+		if rec == "" {
+			continue
+		}
+		fields := strings.Split(rec, fieldSep)
+		if len(fields) < 6 {
+			continue
+		}
+		c := Commit{
+			SHA: fields[0], Author: fields[1], Email: fields[2],
+			Date: fields[3], Subject: fields[4], Body: strings.TrimSpace(fields[5]),
+		}
+		commits = append(commits, c)
+	}
+	return commits
+}
+
+// Log returns up to limit commits reachable from ref, newest first.
+func Log(repoPath, ref string, limit int) ([]Commit, error) {
+	cmd := gitCommand(repoPath, "log", "-n", strconv.Itoa(limit), "--format="+logFormat, ref, "--")
+	// trailing `--` disambiguates the rev from paths (ref is regex-validated upstream).
+	var stderr bytes.Buffer
+	cmd.Stderr = &stderr
+	out, err := cmd.Output()
+	if err != nil {
+		return nil, fmt.Errorf("git log %s in %s: %w: %s", ref, repoPath, err, strings.TrimSpace(stderr.String()))
+	}
+	return parseLog(out), nil
+}
+
+// CommitAt returns the single commit addressed by rev (full or short sha).
+func CommitAt(repoPath, rev string) (Commit, error) {
+	commits, err := Log(repoPath, rev, 1)
+	if err != nil {
+		return Commit{}, err
+	}
+	if len(commits) == 0 {
+		return Commit{}, fmt.Errorf("commit %s: %w", rev, ErrNotFound)
+	}
+	parents, err := parentsOf(repoPath, commits[0].SHA)
+	if err != nil {
+		return Commit{}, err
+	}
+	commits[0].Parents = parents
+	return commits[0], nil
+}
+
+func parentsOf(repoPath, sha string) ([]string, error) {
+	cmd := gitCommand(repoPath, "rev-list", "--parents", "-n", "1", sha)
+	var stderr bytes.Buffer
+	cmd.Stderr = &stderr
+	out, err := cmd.Output()
+	if err != nil {
+		return nil, fmt.Errorf("git rev-list %s: %w: %s", sha, err, strings.TrimSpace(stderr.String()))
+	}
+	fields := strings.Fields(string(out))
+	if len(fields) < 2 {
+		return nil, nil // root commit
+	}
+	return fields[1:], nil
+}
+
+// ShowPatch returns the diff a commit introduces (format suppressed, so
+// only the patch body), reading at most limit+1 bytes.
+func ShowPatch(repoPath, sha string, limit int) ([]byte, error) {
+	cmd := gitCommand(repoPath, "show", "--format=", "--patch", "--find-renames", sha)
+	var stderr bytes.Buffer
+	cmd.Stderr = &stderr
+	out, oversized, err := runBounded(cmd, limit)
+	if oversized {
+		return out, nil
+	}
+	if err != nil {
+		return nil, fmt.Errorf("git show %s: %w: %s", sha, err, strings.TrimSpace(stderr.String()))
+	}
+	return out, nil
+}
+
+// CommitCount returns the number of commits reachable from ref.
+func CommitCount(repoPath, ref string) (int, error) {
+	cmd := gitCommand(repoPath, "rev-list", "--count", ref)
+	var stderr bytes.Buffer
+	cmd.Stderr = &stderr
+	out, err := cmd.Output()
+	if err != nil {
+		return 0, fmt.Errorf("git rev-list --count %s: %w: %s", ref, err, strings.TrimSpace(stderr.String()))
+	}
+	n, err := strconv.Atoi(strings.TrimSpace(string(out)))
+	if err != nil {
+		return 0, fmt.Errorf("git rev-list --count %s: parse %q: %w", ref, out, err)
+	}
+	return n, nil
+}
diff --git a/internal/git/browse_test.go b/internal/git/browse_test.go
new file mode 100644
index 0000000..7eacd35
--- /dev/null
+++ b/internal/git/browse_test.go
@@ -0,0 +1,181 @@
+package git
+
+import (
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func runGit(t *testing.T, dir string, args ...string) {
+	t.Helper()
+	cmd := exec.Command("git", args...)
+	cmd.Dir = dir
+	if out, err := cmd.CombinedOutput(); err != nil {
+		t.Fatalf("git %v in %s: %v: %s", args, dir, err, out)
+	}
+}
+
+// seedBare gives a bare repo one commit on main with a few files.
+func seedBare(t *testing.T, barePath string) {
+	t.Helper()
+	work := filepath.Join(t.TempDir(), "work")
+	runGit(t, "", "clone", "-q", barePath, work)
+	files := map[string]string{
+		"README.md":    "# demo\n\n**hi** there\n",
+		"LICENSE":      "MIT License\n\nPermission is hereby granted, free of charge...\n",
+		"hello.c":      "int main(void) { return 0; }\n",
+		"sub/note.txt": "note\n",
+	}
+	for rel, content := range files {
+		full := filepath.Join(work, rel)
+		if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
+			t.Fatalf("mkdir: %v", err)
+		}
+		if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
+			t.Fatalf("write %s: %v", rel, err)
+		}
+	}
+	runGit(t, work, "add", ".")
+	runGit(t, work, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "seed")
+	runGit(t, work, "push", "-q", "origin", "main")
+}
+
+func TestRefExists(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "demo.git")
+	if err := InitBare(path, "main"); err != nil {
+		t.Fatalf("InitBare: %v", err)
+	}
+	exists, err := RefExists(path, "main")
+	if err != nil {
+		t.Fatalf("RefExists: %v", err)
+	}
+	if exists {
+		t.Error("fresh bare repo resolves main, want empty")
+	}
+	seedBare(t, path)
+	if exists, err := RefExists(path, "main"); err != nil || !exists {
+		t.Fatalf("RefExists after seed = (%v, %v), want (true, nil)", exists, err)
+	}
+	if exists, err := RefExists(path, "nosuchbranch"); err != nil || exists {
+		t.Errorf("RefExists nosuchbranch = (%v, %v), want (false, nil)", exists, err)
+	}
+}
+
+func TestLsTree(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "demo.git")
+	if err := InitBare(path, "main"); err != nil {
+		t.Fatalf("InitBare: %v", err)
+	}
+	seedBare(t, path)
+
+	entries, err := LsTree(path, "main", "")
+	if err != nil {
+		t.Fatalf("LsTree root: %v", err)
+	}
+	types := map[string]string{}
+	for _, e := range entries {
+		types[e.Path] = e.Type
+	}
+	if len(entries) != 4 {
+		t.Errorf("root entries = %d, want 4: %+v", len(entries), entries)
+	}
+	if types["README.md"] != "blob" || types["LICENSE"] != "blob" || types["hello.c"] != "blob" {
+		t.Errorf("root blobs wrong: %+v", types)
+	}
+	if types["sub"] != "tree" {
+		t.Errorf("sub type = %q, want tree", types["sub"])
+	}
+
+	sub, err := LsTree(path, "main", "sub")
+	if err != nil {
+		t.Fatalf("LsTree sub: %v", err)
+	}
+	if len(sub) != 1 || sub[0].Path != "sub/note.txt" || sub[0].Type != "blob" {
+		t.Errorf("sub entries = %+v, want one blob at sub/note.txt", sub)
+	}
+}
+
+func TestShowFile(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "demo.git")
+	if err := InitBare(path, "main"); err != nil {
+		t.Fatalf("InitBare: %v", err)
+	}
+	seedBare(t, path)
+
+	content, err := ShowFile(path, "main", "README.md", 1<<20)
+	if err != nil {
+		t.Fatalf("ShowFile: %v", err)
+	}
+	if !strings.Contains(string(content), "**hi** there") {
+		t.Errorf("content = %q", content)
+	}
+	if _, err := ShowFile(path, "main", "nope.txt", 1<<20); err == nil {
+		t.Error("ShowFile missing file = nil error, want error")
+	} else if !strings.Contains(err.Error(), "not found") && !strings.Contains(err.Error(), "does not exist") {
+		t.Logf("missing-file error text: %v", err)
+	}
+}
+
+// An oversized object must never be buffered in full: ShowFile stops at
+// limit+1 bytes and kills the subprocess.
+func TestShowFileBounded(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "demo.git")
+	if err := InitBare(path, "main"); err != nil {
+		t.Fatalf("InitBare: %v", err)
+	}
+	seedBare(t, path)
+
+	content, err := ShowFile(path, "main", "README.md", 4)
+	if err != nil {
+		t.Fatalf("ShowFile bounded: %v", err)
+	}
+	if len(content) != 5 {
+		t.Errorf("bounded content = %d bytes (%q), want limit+1 = 5", len(content), content)
+	}
+}
+
+// A hostile parent environment (GIT_DIR et al.) must not redirect browse
+// commands to a different repository — gitCommand pins a minimal env.
+func TestBrowseIgnoresInheritedGitEnv(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "demo.git")
+	if err := InitBare(path, "main"); err != nil {
+		t.Fatalf("InitBare: %v", err)
+	}
+	seedBare(t, path)
+
+	decoy := filepath.Join(t.TempDir(), "decoy.git")
+	if err := InitBare(decoy, "main"); err != nil {
+		t.Fatalf("InitBare decoy: %v", err)
+	}
+	work := filepath.Join(t.TempDir(), "decoy-work")
+	runGit(t, "", "init", "-q", "-b", "main", work)
+	if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("DECOY\n"), 0o644); err != nil {
+		t.Fatalf("write decoy readme: %v", err)
+	}
+	runGit(t, work, "add", ".")
+	runGit(t, work, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "decoy")
+	runGit(t, work, "push", "-q", decoy, "main")
+
+	t.Setenv("GIT_DIR", decoy)
+	t.Setenv("GIT_WORK_TREE", work)
+
+	content, err := ShowFile(path, "main", "README.md", 1<<20)
+	if err != nil {
+		t.Fatalf("ShowFile with hostile GIT_DIR: %v", err)
+	}
+	if strings.Contains(string(content), "DECOY") {
+		t.Errorf("ShowFile served the decoy repo: %q", content)
+	}
+	if !strings.Contains(string(content), "**hi** there") {
+		t.Errorf("ShowFile content = %q, want the seeded README", content)
+	}
+	entries, err := LsTree(path, "main", "")
+	if err != nil {
+		t.Fatalf("LsTree with hostile GIT_DIR: %v", err)
+	}
+	if len(entries) != 4 {
+		t.Errorf("LsTree returned %d entries, want 4 from demo (decoy has 1)", len(entries))
+	}
+}
diff --git a/internal/git/commits_test.go b/internal/git/commits_test.go
new file mode 100644
index 0000000..41c7eac
--- /dev/null
+++ b/internal/git/commits_test.go
@@ -0,0 +1,67 @@
+package git
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func TestLogCommitAtShowPatch(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "demo.git")
+	if err := InitBare(path, "main"); err != nil {
+		t.Fatalf("InitBare: %v", err)
+	}
+	seedBare(t, path)
+
+	work := filepath.Join(t.TempDir(), "work2")
+	runGit(t, "", "clone", "-q", path, work)
+	if err := os.WriteFile(filepath.Join(work, "new.txt"), []byte("new\n"), 0o644); err != nil {
+		t.Fatalf("write: %v", err)
+	}
+	runGit(t, work, "add", ".")
+	runGit(t, work, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "second")
+	runGit(t, work, "push", "-q", "origin", "main")
+
+	logs, err := Log(path, "main", 50)
+	if err != nil {
+		t.Fatalf("Log: %v", err)
+	}
+	if len(logs) != 2 {
+		t.Fatalf("log length = %d, want 2: %+v", len(logs), logs)
+	}
+	if logs[0].Subject != "second" || logs[1].Subject != "seed" {
+		t.Errorf("subjects = %q, %q, want second, seed", logs[0].Subject, logs[1].Subject)
+	}
+	if logs[0].Author != "t" || !strings.Contains(logs[0].Date, "T") {
+		t.Errorf("first commit author/date = %q %q", logs[0].Author, logs[0].Date)
+	}
+	// Log lists rows only; parents come from CommitAt.
+	head, err := CommitAt(path, logs[0].SHA)
+	if err != nil {
+		t.Fatalf("CommitAt head: %v", err)
+	}
+	if len(head.Parents) != 1 || head.Parents[0] != logs[1].SHA {
+		t.Errorf("head parents = %v, want [%s]", head.Parents, logs[1].SHA)
+	}
+
+	root, err := CommitAt(path, logs[1].SHA[:8])
+	if err != nil {
+		t.Fatalf("CommitAt short sha: %v", err)
+	}
+	if root.Subject != "seed" || len(root.Parents) != 0 {
+		t.Errorf("root commit = %+v, want seed with no parents", root)
+	}
+
+	patch, err := ShowPatch(path, logs[0].SHA, 1<<20)
+	if err != nil {
+		t.Fatalf("ShowPatch: %v", err)
+	}
+	if !strings.Contains(string(patch), "+++ b/new.txt") {
+		t.Errorf("patch lacks new file diff: %q", patch)
+	}
+
+	if _, err := Log(path, "nosuchref", 10); err == nil {
+		t.Error("Log on missing ref = nil error, want error")
+	}
+}
diff --git a/internal/git/git.go b/internal/git/git.go
new file mode 100644
index 0000000..e4c45fc
--- /dev/null
+++ b/internal/git/git.go
@@ -0,0 +1,130 @@
+// Package git is the only package allowed to exec the git binary.
+// Every subprocess runs with an explicit, minimal environment so
+// inherited GIT_* variables cannot redirect commands to another
+// repository or inject git configuration.
+package git
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"time"
+)
+
+// gitTimeout bounds every browse/merge subprocess; the CGI streaming path
+// (backend.go) is exempt — pushes stream bodies of arbitrary duration.
+const gitTimeout = 2 * time.Minute
+
+// gitCommand builds a git subprocess rooted at repoPath ("" to inherit
+// the process working directory) with a minimal, explicit environment.
+// It is bounded by gitTimeout; the context's cancel releases the deadline
+// timer when it fires and doubles as the lostcancel silencer.
+func gitCommand(repoPath string, args ...string) *exec.Cmd {
+	ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
+	cmd := exec.CommandContext(ctx, "git", args...)
+	cmd.Cancel = func() error {
+		err := cmd.Process.Kill()
+		cancel()
+		return err
+	}
+	cmd.Dir = repoPath
+	cmd.Env = []string{
+		"PATH=" + os.Getenv("PATH"),
+		"LANG=C",
+		"LC_ALL=C",
+	}
+	return cmd
+}
+
+// runBounded runs cmd and returns at most limit+1 bytes of its stdout.
+// When the output exceeds limit the subprocess is killed early and
+// oversized is true, so a huge object or patch never lands fully in
+// memory; callers decide what the cap means. Any stderr buffer must be
+// attached to cmd before the call; wait errors surface unformatted.
+func runBounded(cmd *exec.Cmd, limit int) (out []byte, oversized bool, err error) {
+	stdout, err := cmd.StdoutPipe()
+	if err != nil {
+		return nil, false, err
+	}
+	if err := cmd.Start(); err != nil {
+		return nil, false, err
+	}
+	var buf bytes.Buffer
+	n, readErr := io.CopyN(&buf, stdout, int64(limit)+1)
+	if n > int64(limit) {
+		stdout.Close()
+		_ = cmd.Process.Kill()
+		_ = cmd.Wait()
+		return buf.Bytes(), true, nil
+	}
+	if readErr != nil && !errors.Is(readErr, io.EOF) {
+		_ = cmd.Wait()
+		return nil, false, readErr
+	}
+	if err := cmd.Wait(); err != nil {
+		return nil, false, err
+	}
+	return buf.Bytes(), false, nil
+}
+
+// InitBare creates a bare repository at path with HEAD pointing at branch,
+// then installs the post-receive hook that calls back into this binary.
+func InitBare(path, branch string) error {
+	cmd := gitCommand("", "init", "--bare", "--initial-branch="+branch, path)
+	if out, err := cmd.CombinedOutput(); err != nil {
+		return fmt.Errorf("git init --bare %s: %w: %s", path, err, strings.TrimSpace(string(out)))
+	}
+	return installPostReceive(path)
+}
+
+// installPostReceive writes hooks/post-receive so a push updates repo
+// metadata. Git runs hooks without our pinned environment, so the callback
+// binary comes from SIMPLEGIT_BIN, which ServeBackend sets explicitly (it
+// is absent for out-of-band pushes, where the hook is a no-op). The repo
+// path is derived from the hook's own location, so a rename keeps working.
+func installPostReceive(repoPath string) error {
+	absRepo, err := filepath.Abs(repoPath)
+	if err != nil {
+		return fmt.Errorf("resolve repo path: %w", err)
+	}
+	script := "#!/bin/sh\n" +
+		"# installed by simplegit; records the push in the metadata DB.\n" +
+		`[ -n "$SIMPLEGIT_BIN" ] || exit 0` + "\n" +
+		`repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)` + "\n" +
+		`exec "$SIMPLEGIT_BIN" hook --repo "$repo"` + "\n"
+	hookPath := filepath.Join(absRepo, "hooks", "post-receive")
+	if err := os.WriteFile(hookPath, []byte(script), 0o755); err != nil {
+		return fmt.Errorf("write post-receive hook: %w", err)
+	}
+	if err := os.Chmod(hookPath, 0o755); err != nil {
+		return fmt.Errorf("chmod post-receive hook: %w", err)
+	}
+	return nil
+}
+
+// DefaultBranch returns the branch HEAD points at (e.g. "main").
+func DefaultBranch(repoPath string) (string, error) {
+	cmd := gitCommand(repoPath, "symbolic-ref", "--short", "HEAD")
+	var stderr bytes.Buffer
+	cmd.Stderr = &stderr
+	out, err := cmd.Output()
+	if err != nil {
+		return "", fmt.Errorf("git symbolic-ref HEAD in %s: %w: %s", repoPath, err, strings.TrimSpace(stderr.String()))
+	}
+	return strings.TrimSpace(string(out)), nil
+}
+
+// SetDefaultBranch points HEAD at refs/heads/branch.
+func SetDefaultBranch(repoPath, branch string) error {
+	cmd := gitCommand(repoPath, "symbolic-ref", "HEAD", "refs/heads/"+branch)
+	if out, err := cmd.CombinedOutput(); err != nil {
+		return fmt.Errorf("git symbolic-ref HEAD %s: %w: %s", branch, err, strings.TrimSpace(string(out)))
+	}
+	return nil
+}
diff --git a/internal/git/git_test.go b/internal/git/git_test.go
new file mode 100644
index 0000000..e2f026b
--- /dev/null
+++ b/internal/git/git_test.go
@@ -0,0 +1,82 @@
+package git
+
+import (
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func TestInitBare(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "demo.git")
+	if err := InitBare(path, "main"); err != nil {
+		t.Fatalf("InitBare: %v", err)
+	}
+
+	head, err := os.ReadFile(filepath.Join(path, "HEAD"))
+	if err != nil {
+		t.Fatalf("read HEAD: %v", err)
+	}
+	if !strings.Contains(string(head), "ref: refs/heads/main") {
+		t.Errorf("HEAD = %q, want ref: refs/heads/main", head)
+	}
+
+	cmd := exec.Command("git", "rev-parse", "--is-bare-repository")
+	cmd.Dir = path
+	if out, err := cmd.CombinedOutput(); err != nil {
+		t.Fatalf("rev-parse: %v: %s", err, out)
+	} else if strings.TrimSpace(string(out)) != "true" {
+		t.Errorf("is-bare-repository = %q, want true", out)
+	}
+}
+
+func TestInitBareReportsFailure(t *testing.T) {
+	file := filepath.Join(t.TempDir(), "occupied")
+	if err := os.WriteFile(file, []byte("x"), 0o644); err != nil {
+		t.Fatalf("write file: %v", err)
+	}
+	if err := InitBare(file, "main"); err == nil {
+		t.Error("InitBare over a regular file = nil, want error")
+	}
+}
+
+func TestInitBareInstallsPostReceive(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "demo.git")
+	if err := InitBare(path, "main"); err != nil {
+		t.Fatalf("InitBare: %v", err)
+	}
+	hook := filepath.Join(path, "hooks", "post-receive")
+	info, err := os.Stat(hook)
+	if err != nil {
+		t.Fatalf("stat post-receive: %v", err)
+	}
+	if info.Mode()&0o111 == 0 {
+		t.Error("post-receive is not executable")
+	}
+	script, err := os.ReadFile(hook)
+	if err != nil {
+		t.Fatalf("read post-receive: %v", err)
+	}
+	for _, want := range []string{"SIMPLEGIT_BIN", "hook --repo", "dirname"} {
+		if !strings.Contains(string(script), want) {
+			t.Errorf("post-receive lacks %q: %q", want, script)
+		}
+	}
+}
+
+func TestDefaultBranchRoundTrip(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "demo.git")
+	if err := InitBare(path, "main"); err != nil {
+		t.Fatalf("InitBare: %v", err)
+	}
+	if branch, err := DefaultBranch(path); err != nil || branch != "main" {
+		t.Fatalf("DefaultBranch = %q, %v; want main", branch, err)
+	}
+	if err := SetDefaultBranch(path, "trunk"); err != nil {
+		t.Fatalf("SetDefaultBranch: %v", err)
+	}
+	if branch, err := DefaultBranch(path); err != nil || branch != "trunk" {
+		t.Errorf("DefaultBranch after set = %q, %v; want trunk", branch, err)
+	}
+}
diff --git a/internal/git/merge.go b/internal/git/merge.go
new file mode 100644
index 0000000..9a516a9
--- /dev/null
+++ b/internal/git/merge.go
@@ -0,0 +1,180 @@
+package git
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"os/exec"
+	"strings"
+)
+
+// ErrMergeConflict marks a merge that cannot complete without resolving
+// conflicts by hand.
+var ErrMergeConflict = errors.New("merge conflict")
+
+// ErrAlreadyMerged marks a head whose changes are already contained in base.
+var ErrAlreadyMerged = errors.New("already merged")
+
+// ErrNoCommonAncestor marks two revisions with unrelated histories.
+var ErrNoCommonAncestor = errors.New("no common ancestor")
+
+// ResolveCommit resolves rev (branch, tag, sha) to a full commit SHA, or
+// ErrNotFound when it does not name a commit.
+func ResolveCommit(repoPath, rev string) (string, error) {
+	cmd := gitCommand(repoPath, "rev-parse", "--verify", "--quiet", rev+"^{commit}")
+	var stderr bytes.Buffer
+	cmd.Stderr = &stderr
+	out, err := cmd.Output()
+	if err != nil {
+		var ee *exec.ExitError
+		if errors.As(err, &ee) && ee.ExitCode() == 1 {
+			return "", fmt.Errorf("rev %s: %w", rev, ErrNotFound)
+		}
+		return "", fmt.Errorf("git rev-parse %s in %s: %w: %s", rev, repoPath, err, strings.TrimSpace(stderr.String()))
+	}
+	return strings.TrimSpace(string(out)), nil
+}
+
+// refsHeads is the branch-ref prefix used to build explicit ref arguments.
+const refsHeads = "refs/heads/"
+
+// Branches lists the local branch names (refs/heads), sorted.
+func Branches(repoPath string) ([]string, error) {
+	return forEachRefNames(repoPath, refsHeads)
+}
+
+// MergeBase returns the best common ancestor of a and b, or
+// ErrNoCommonAncestor when the histories are unrelated.
+func MergeBase(repoPath, a, b string) (string, error) {
+	cmd := gitCommand(repoPath, "merge-base", a, b)
+	var stderr bytes.Buffer
+	cmd.Stderr = &stderr
+	out, err := cmd.Output()
+	if err != nil {
+		var ee *exec.ExitError
+		if errors.As(err, &ee) && ee.ExitCode() == 1 {
+			return "", fmt.Errorf("merge-base %s %s: %w", a, b, ErrNoCommonAncestor)
+		}
+		return "", fmt.Errorf("git merge-base %s %s: %w: %s", a, b, err, strings.TrimSpace(stderr.String()))
+	}
+	return strings.TrimSpace(string(out)), nil
+}
+
+// Diff returns the patch that head introduces on top of its merge base with
+// base (git's three-dot form), renames detected and colour suppressed.
+// At most limit+1 bytes are read; callers detect the cap with len.
+func Diff(repoPath, base, head string, limit int) ([]byte, error) {
+	cmd := gitCommand(repoPath, "diff", "--no-color", "--patch", "--find-renames", base+"..."+head)
+	var stderr bytes.Buffer
+	cmd.Stderr = &stderr
+	out, oversized, err := runBounded(cmd, limit)
+	if oversized {
+		return out, nil
+	}
+	if err != nil {
+		return nil, fmt.Errorf("git diff %s...%s: %w: %s", base, head, err, strings.TrimSpace(stderr.String()))
+	}
+	return out, nil
+}
+
+// Merge advances the base branch to include head: a fast-forward when base
+// is an ancestor of head, otherwise a two-parent merge commit. It updates
+// refs/heads/base and reports the new head SHA and whether it fast-forwarded.
+// authorName becomes the merge commit's author/committer (the host has no
+// global git identity to inherit).
+func Merge(repoPath, base, head, message, authorName string) (sha string, fastForward bool, err error) {
+	baseSHA, err := ResolveCommit(repoPath, refsHeads+base)
+	if err != nil {
+		return "", false, fmt.Errorf("merge base %s: %w", base, err)
+	}
+	headSHA, err := ResolveCommit(repoPath, refsHeads+head)
+	if err != nil {
+		return "", false, fmt.Errorf("merge head %s: %w", head, err)
+	}
+
+	mergeBase, err := MergeBase(repoPath, baseSHA, headSHA)
+	if err != nil {
+		return "", false, err
+	}
+	if mergeBase == baseSHA {
+		if err := updateRef(repoPath, refsHeads+base, headSHA); err != nil {
+			return "", false, err
+		}
+		return headSHA, true, nil
+	}
+	if mergeBase == headSHA {
+		// head is already an ancestor of base: its changes are contained.
+		return headSHA, true, ErrAlreadyMerged
+	}
+
+	tree, conflict, err := mergeTree(repoPath, baseSHA, headSHA)
+	if err != nil {
+		return "", false, err
+	}
+	if conflict {
+		return "", false, fmt.Errorf("merge %s into %s: %w", head, base, ErrMergeConflict)
+	}
+	commit, err := commitTree(repoPath, tree, baseSHA, headSHA, message, authorName)
+	if err != nil {
+		return "", false, err
+	}
+	if err := updateRef(repoPath, refsHeads+base, commit); err != nil {
+		return "", false, err
+	}
+	return commit, false, nil
+}
+
+// mergeTree merges two commits without a work tree, writing the result tree
+// and reporting whether the merge conflicted.
+func mergeTree(repoPath, baseSHA, headSHA string) (tree string, conflict bool, err error) {
+	cmd := gitCommand(repoPath, "merge-tree", "--write-tree", baseSHA, headSHA)
+	var stdout, stderr bytes.Buffer
+	cmd.Stdout = &stdout
+	cmd.Stderr = &stderr
+	runErr := cmd.Run()
+	if runErr != nil {
+		var ee *exec.ExitError
+		if errors.As(runErr, &ee) && ee.ExitCode() == 1 {
+			return "", true, nil
+		}
+		return "", false, fmt.Errorf("git merge-tree: %w: %s", runErr, strings.TrimSpace(stderr.String()))
+	}
+	line, _, _ := strings.Cut(stdout.String(), "\n")
+	if line = strings.TrimSpace(line); line == "" {
+		return "", false, errors.New("git merge-tree: no tree produced")
+	}
+	return line, false, nil
+}
+
+// commitTree creates a two-parent merge commit object.
+func commitTree(repoPath, tree, parentA, parentB, message, authorName string) (string, error) {
+	if authorName == "" {
+		authorName = "simplegit"
+	}
+	cmd := gitCommand(repoPath, "commit-tree", tree, "-p", parentA, "-p", parentB, "-m", message)
+	// gitCommand pins a minimal env with no identity and no HOME, so supply
+	// one explicitly; the merge commit must record some author.
+	cmd.Env = append(cmd.Env,
+		"GIT_AUTHOR_NAME="+authorName,
+		"GIT_AUTHOR_EMAIL="+authorName+"@simplegit",
+		"GIT_COMMITTER_NAME="+authorName,
+		"GIT_COMMITTER_EMAIL="+authorName+"@simplegit",
+	)
+	var stderr bytes.Buffer
+	cmd.Stderr = &stderr
+	out, err := cmd.Output()
+	if err != nil {
+		return "", fmt.Errorf("git commit-tree: %w: %s", err, strings.TrimSpace(stderr.String()))
+	}
+	return strings.TrimSpace(string(out)), nil
+}
+
+func updateRef(repoPath, ref, sha string) error {
+	cmd := gitCommand(repoPath, "update-ref", ref, sha)
+	var stderr bytes.Buffer
+	cmd.Stderr = &stderr
+	if err := cmd.Run(); err != nil {
+		return fmt.Errorf("git update-ref %s: %w: %s", ref, err, strings.TrimSpace(stderr.String()))
+	}
+	return nil
+}
diff --git a/internal/git/merge_test.go b/internal/git/merge_test.go
new file mode 100644
index 0000000..504fae9
--- /dev/null
+++ b/internal/git/merge_test.go
@@ -0,0 +1,159 @@
+package git
+
+import (
+	"errors"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func writeFile(t *testing.T, dir, rel, content string) {
+	t.Helper()
+	full := filepath.Join(dir, rel)
+	if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
+		t.Fatalf("mkdir: %v", err)
+	}
+	if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
+		t.Fatalf("write %s: %v", rel, err)
+	}
+}
+
+// bareWithMain returns a bare repo and a clone whose main has one commit.
+func bareWithMain(t *testing.T) (bare, work string) {
+	t.Helper()
+	bare = filepath.Join(t.TempDir(), "m.git")
+	runGit(t, "", "init", "-q", "--bare", "--initial-branch=main", bare)
+	work = filepath.Join(t.TempDir(), "work")
+	runGit(t, "", "clone", "-q", bare, work)
+	runGit(t, work, "config", "user.email", "t@t")
+	runGit(t, work, "config", "user.name", "t")
+	writeFile(t, work, "base.txt", "base\n")
+	runGit(t, work, "add", ".")
+	runGit(t, work, "commit", "-qm", "base")
+	runGit(t, work, "push", "-q", "origin", "main")
+	return bare, work
+}
+
+func TestMergeFastForward(t *testing.T) {
+	bare, work := bareWithMain(t)
+	runGit(t, work, "checkout", "-qb", "feature")
+	writeFile(t, work, "feature.txt", "feature\n")
+	runGit(t, work, "add", ".")
+	runGit(t, work, "commit", "-qm", "feature work")
+	runGit(t, work, "push", "-q", "origin", "feature")
+	headSHA, err := ResolveCommit(bare, "refs/heads/feature")
+	if err != nil {
+		t.Fatalf("ResolveCommit: %v", err)
+	}
+
+	sha, ff, err := Merge(bare, "main", "feature", "Merge feature", "tester")
+	if err != nil {
+		t.Fatalf("Merge: %v", err)
+	}
+	if !ff {
+		t.Error("Merge reported not fast-forward, want fast-forward")
+	}
+	if sha != headSHA {
+		t.Errorf("merge sha = %s, want feature head %s", sha, headSHA)
+	}
+	mainSHA, _ := ResolveCommit(bare, "refs/heads/main")
+	if mainSHA != headSHA {
+		t.Errorf("main = %s, want advanced to %s", mainSHA, headSHA)
+	}
+}
+
+func TestMergeCreatesMergeCommit(t *testing.T) {
+	bare, work := bareWithMain(t)
+	runGit(t, work, "checkout", "-qb", "feature")
+	writeFile(t, work, "feature.txt", "feature\n")
+	runGit(t, work, "add", ".")
+	runGit(t, work, "commit", "-qm", "feature work")
+	runGit(t, work, "push", "-q", "origin", "feature")
+	runGit(t, work, "checkout", "-q", "main")
+	writeFile(t, work, "main.txt", "main\n")
+	runGit(t, work, "add", ".")
+	runGit(t, work, "commit", "-qm", "main work")
+	runGit(t, work, "push", "-q", "origin", "main")
+
+	sha, ff, err := Merge(bare, "main", "feature", "Merge feature", "tester")
+	if err != nil {
+		t.Fatalf("Merge: %v", err)
+	}
+	if ff {
+		t.Error("Merge reported fast-forward, want a merge commit")
+	}
+	commit, err := CommitAt(bare, sha)
+	if err != nil {
+		t.Fatalf("CommitAt merge: %v", err)
+	}
+	if len(commit.Parents) != 2 {
+		t.Errorf("merge parents = %d, want 2", len(commit.Parents))
+	}
+	entries, err := LsTree(bare, "refs/heads/main", "")
+	if err != nil {
+		t.Fatalf("LsTree: %v", err)
+	}
+	var names []string
+	for _, e := range entries {
+		names = append(names, filepath.Base(e.Path))
+	}
+	joined := strings.Join(names, ",")
+	if !strings.Contains(joined, "feature.txt") || !strings.Contains(joined, "main.txt") {
+		t.Errorf("merged tree = %v, want both feature.txt and main.txt", names)
+	}
+}
+
+func TestMergeConflictLeavesBase(t *testing.T) {
+	bare, work := bareWithMain(t)
+	writeFile(t, work, "conflict.txt", "original\n")
+	runGit(t, work, "add", ".")
+	runGit(t, work, "commit", "-qm", "add conflict file")
+	runGit(t, work, "push", "-q", "origin", "main")
+
+	runGit(t, work, "checkout", "-qb", "feature")
+	writeFile(t, work, "conflict.txt", "feature side\n")
+	runGit(t, work, "add", ".")
+	runGit(t, work, "commit", "-qm", "feature edit")
+	runGit(t, work, "push", "-q", "origin", "feature")
+
+	runGit(t, work, "checkout", "-q", "main")
+	writeFile(t, work, "conflict.txt", "main side\n")
+	runGit(t, work, "add", ".")
+	runGit(t, work, "commit", "-qm", "main edit")
+	runGit(t, work, "push", "-q", "origin", "main")
+	before, _ := ResolveCommit(bare, "refs/heads/main")
+
+	if _, _, err := Merge(bare, "main", "feature", "Merge feature", "tester"); !errors.Is(err, ErrMergeConflict) {
+		t.Fatalf("Merge err = %v, want ErrMergeConflict", err)
+	}
+	after, _ := ResolveCommit(bare, "refs/heads/main")
+	if after != before {
+		t.Errorf("main moved on conflict: %s -> %s", before, after)
+	}
+}
+
+func TestDiffThreeDot(t *testing.T) {
+	bare, work := bareWithMain(t)
+	runGit(t, work, "checkout", "-qb", "feature")
+	writeFile(t, work, "feature.txt", "feature\n")
+	runGit(t, work, "add", ".")
+	runGit(t, work, "commit", "-qm", "feature work")
+	runGit(t, work, "push", "-q", "origin", "feature")
+
+	patch, err := Diff(bare, "main", "feature", 1<<20)
+	if err != nil {
+		t.Fatalf("Diff: %v", err)
+	}
+	if !strings.Contains(string(patch), "feature.txt") {
+		t.Errorf("diff missing feature.txt: %s", patch)
+	}
+
+	branches, err := Branches(bare)
+	if err != nil {
+		t.Fatalf("Branches: %v", err)
+	}
+	if len(branches) != 2 || branches[0] != "feature" || branches[1] != "main" {
+		t.Errorf("branches = %v, want [feature main]", branches)
+	}
+}
diff --git a/internal/git/tags.go b/internal/git/tags.go
new file mode 100644
index 0000000..993d697
--- /dev/null
+++ b/internal/git/tags.go
@@ -0,0 +1,52 @@
+package git
+
+import (
+	"bytes"
+	"fmt"
+	"strings"
+)
+
+// Tag is a tag ref in the repository. Commit is the commit the tag points
+// at (dereferenced for an annotated tag); Annotated reports whether the tag
+// is an annotated tag object rather than a lightweight ref.
+type Tag struct {
+	Name      string
+	Commit    string
+	Annotated bool
+}
+
+// Tags lists refs/tags, sorted by name. for-each-ref's %(*objectname)
+// dereferences an annotated tag to the commit it names, so both tag kinds
+// report a commit SHA.
+func Tags(repoPath string) ([]Tag, error) {
+	cmd := gitCommand(repoPath, "for-each-ref",
+		"--format=%(refname:short) %(objecttype) %(objectname) %(*objectname)", "refs/tags/")
+	var stderr bytes.Buffer
+	cmd.Stderr = &stderr
+	out, err := cmd.Output()
+	if err != nil {
+		return nil, fmt.Errorf("git for-each-ref refs/tags/ in %s: %w: %s", repoPath, err, strings.TrimSpace(stderr.String()))
+	}
+
+	var tags []Tag
+	for _, line := range strings.Split(string(out), "\n") {
+		fields := strings.Fields(line)
+		if len(fields) < 3 {
+			continue
+		}
+		tag := Tag{Name: fields[0], Annotated: fields[1] == "tag"}
+		switch {
+		case len(fields) >= 4:
+			tag.Commit = fields[3]
+		case tag.Annotated:
+			// Annotated tag whose target is not a commit: resolve it.
+			if sha, err := ResolveCommit(repoPath, fields[0]); err == nil {
+				tag.Commit = sha
+			}
+		default:
+			tag.Commit = fields[2]
+		}
+		tags = append(tags, tag)
+	}
+	return tags, nil
+}
diff --git a/internal/git/tags_test.go b/internal/git/tags_test.go
new file mode 100644
index 0000000..88a137a
--- /dev/null
+++ b/internal/git/tags_test.go
@@ -0,0 +1,57 @@
+package git
+
+import (
+	"path/filepath"
+	"testing"
+)
+
+func TestTagsLightweightAndAnnotated(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "demo.git")
+	if err := InitBare(path, "main"); err != nil {
+		t.Fatalf("InitBare: %v", err)
+	}
+	seedBare(t, path)
+	runGit(t, path, "tag", "v1", "main")
+	runGit(t, path, "-c", "user.email=t@t", "-c", "user.name=t",
+		"tag", "-a", "v2", "-m", "release two", "main")
+
+	mainSHA, err := ResolveCommit(path, "main")
+	if err != nil {
+		t.Fatalf("ResolveCommit main: %v", err)
+	}
+	tags, err := Tags(path)
+	if err != nil {
+		t.Fatalf("Tags: %v", err)
+	}
+	if len(tags) != 2 {
+		t.Fatalf("len(tags) = %d, want 2 (%+v)", len(tags), tags)
+	}
+	if tags[0].Name != "v1" || tags[1].Name != "v2" {
+		t.Errorf("tag order = %q, %q, want v1, v2", tags[0].Name, tags[1].Name)
+	}
+	if tags[0].Annotated {
+		t.Error("v1 reported annotated, want lightweight")
+	}
+	if !tags[1].Annotated {
+		t.Error("v2 reported lightweight, want annotated")
+	}
+	for _, tag := range tags {
+		if tag.Commit != mainSHA {
+			t.Errorf("tag %s commit = %s, want %s", tag.Name, tag.Commit, mainSHA)
+		}
+	}
+}
+
+func TestTagsEmptyRepo(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "empty.git")
+	if err := InitBare(path, "main"); err != nil {
+		t.Fatalf("InitBare: %v", err)
+	}
+	tags, err := Tags(path)
+	if err != nil {
+		t.Fatalf("Tags: %v", err)
+	}
+	if len(tags) != 0 {
+		t.Errorf("tags in empty repo = %+v, want none", tags)
+	}
+}
diff --git a/internal/render/render.go b/internal/render/render.go
new file mode 100644
index 0000000..6ffbcb7
--- /dev/null
+++ b/internal/render/render.go
@@ -0,0 +1,147 @@
+// Package render converts repository content for the browser.
+package render
+
+import (
+	"bytes"
+	"fmt"
+
+	"github.com/alecthomas/chroma/v2"
+	chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
+	"github.com/alecthomas/chroma/v2/lexers"
+	"github.com/alecthomas/chroma/v2/styles"
+	"github.com/yuin/goldmark"
+	"github.com/yuin/goldmark/ast"
+	"github.com/yuin/goldmark/renderer"
+	"github.com/yuin/goldmark/util"
+)
+
+// htmlEscaper replaces goldmark's pass-through rendering of raw HTML
+// (block and inline) with escaped text, so READMEs cannot inject script.
+type htmlEscaper struct{}
+
+func (htmlEscaper) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
+	reg.Register(ast.KindHTMLBlock, escapeHTMLBlock)
+	reg.Register(ast.KindRawHTML, escapeRawHTML)
+}
+
+func escapeHTMLBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+	if entering {
+		n := node.(*ast.HTMLBlock)
+		lines := n.Lines()
+		for i := range lines.Len() {
+			segment := lines.At(i)
+			if _, err := w.Write(util.EscapeHTML(segment.Value(source))); err != nil {
+				return ast.WalkStop, err
+			}
+		}
+		if n.HasClosure() {
+			if _, err := w.Write(util.EscapeHTML(n.ClosureLine.Value(source))); err != nil {
+				return ast.WalkStop, err
+			}
+		}
+	}
+	return ast.WalkSkipChildren, nil
+}
+
+func escapeRawHTML(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+	if entering {
+		if _, err := w.Write(util.EscapeHTML(node.Text(source))); err != nil {
+			return ast.WalkStop, err
+		}
+	}
+	return ast.WalkSkipChildren, nil
+}
+
+var markdown = goldmark.New(
+	goldmark.WithRendererOptions(
+		renderer.WithNodeRenderers(util.Prioritized(htmlEscaper{}, 50)),
+	),
+)
+
+// Markdown renders markdown source to HTML. Raw HTML in the source is
+// escaped rather than passed through.
+func Markdown(source []byte) ([]byte, error) {
+	var out bytes.Buffer
+	if err := markdown.Convert(source, &out); err != nil {
+		return nil, fmt.Errorf("render markdown: %w", err)
+	}
+	return out.Bytes(), nil
+}
+
+// highlightStyle inherits chroma's monokai (for complete token coverage)
+// and overrides the tokens that matter to the simplegit palette. The class
+// names are emitted by the formatter; their rules come from ChromaCSS.
+var highlightStyle = func() *chroma.Style {
+	style, err := styles.Get("monokai").Builder().
+		Add(chroma.Background, "bg:#1e1f3a").
+		Add(chroma.Text, "#dcdcf5").
+		Add(chroma.Comment, "italic #8f92c4").
+		Add(chroma.Keyword, "#a5aef0").
+		Add(chroma.KeywordConstant, "#e0af68").
+		Add(chroma.KeywordDeclaration, "#a5aef0").
+		Add(chroma.KeywordNamespace, "#a5aef0").
+		Add(chroma.KeywordType, "#e0af68").
+		Add(chroma.Name, "#dcdcf5").
+		Add(chroma.NameOther, "#dcdcf5").
+		Add(chroma.NameConstant, "#e0af68").
+		Add(chroma.NameException, "#e06c75").
+		Add(chroma.NameBuiltin, "#e0af68").
+		Add(chroma.NameClass, "#7bc275").
+		Add(chroma.NameFunction, "#a5aef0").
+		Add(chroma.NameDecorator, "#e0af68").
+		Add(chroma.NameTag, "#a5aef0").
+		Add(chroma.NameAttribute, "#e0af68").
+		Add(chroma.String, "#7bc275").
+		Add(chroma.LiteralStringEscape, "#e0af68").
+		Add(chroma.Number, "#e0af68").
+		Add(chroma.Literal, "#7bc275").
+		Add(chroma.LiteralDate, "#e0af68").
+		Add(chroma.Operator, "#8f92c4").
+		Add(chroma.Punctuation, "#8f92c4").
+		Add(chroma.GenericDeleted, "#e06c75").
+		Add(chroma.GenericInserted, "#7bc275").
+		Add(chroma.GenericHeading, "#a5aef0").
+		Add(chroma.GenericSubheading, "#a5aef0").
+		Add(chroma.GenericEmph, "italic").
+		Add(chroma.GenericStrong, "bold").
+		Add(chroma.Error, "underline #e06c75").
+		Add(chroma.LineHighlight, "bg:#2a2c52").
+		Add(chroma.LineNumbers, "#8f92c4").
+		Add(chroma.LineNumbersTable, "#8f92c4").
+		Build()
+	if err != nil {
+		panic("render: build highlight style: " + err.Error())
+	}
+	return style
+}()
+
+// highlighter emits CSS classes rather than inline styles, so the palette
+// lives in ChromaCSS and can be tuned against design.md.
+var highlighter = chromahtml.New(chromahtml.WithClasses(true))
+
+// ChromaCSS returns the stylesheet that styles the classes CodeHTML emits.
+func ChromaCSS() ([]byte, error) {
+	var buf bytes.Buffer
+	if err := highlighter.WriteCSS(&buf, highlightStyle); err != nil {
+		return nil, fmt.Errorf("render chroma css: %w", err)
+	}
+	return buf.Bytes(), nil
+}
+
+// CodeHTML syntax-highlights a file by name. handled=false means no lexer
+// matched and the caller should fall back to a plain escaped <pre>.
+func CodeHTML(filename, content string) (html string, handled bool, err error) {
+	lexer := lexers.Match(filename)
+	if lexer == nil {
+		return "", false, nil
+	}
+	it, err := lexer.Tokenise(nil, content)
+	if err != nil {
+		return "", true, fmt.Errorf("tokenise %s: %w", filename, err)
+	}
+	var buf bytes.Buffer
+	if err := highlighter.Format(&buf, highlightStyle, it); err != nil {
+		return "", true, fmt.Errorf("highlight %s: %w", filename, err)
+	}
+	return buf.String(), true, nil
+}
diff --git a/internal/render/render_test.go b/internal/render/render_test.go
new file mode 100644
index 0000000..6918f55
--- /dev/null
+++ b/internal/render/render_test.go
@@ -0,0 +1,85 @@
+package render
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestMarkdownRenders(t *testing.T) {
+	out, err := Markdown([]byte("# demo\n\n**hi** there\n"))
+	if err != nil {
+		t.Fatalf("Markdown: %v", err)
+	}
+	html := string(out)
+	if !strings.Contains(html, "<strong>hi</strong>") {
+		t.Errorf("html = %q, want bold", html)
+	}
+}
+
+func TestMarkdownEscapesRawHTML(t *testing.T) {
+	out, err := Markdown([]byte("<script>alert(1)</script>\n"))
+	if err != nil {
+		t.Fatalf("Markdown: %v", err)
+	}
+	html := string(out)
+	if strings.Contains(html, "<script>") {
+		t.Errorf("raw script passed through: %q", html)
+	}
+	if !strings.Contains(html, "&lt;script&gt;") {
+		t.Errorf("script not escaped: %q", html)
+	}
+}
+
+func TestCodeHTMLHighlightsKnownExt(t *testing.T) {
+	html, handled, err := CodeHTML("main.go", "package main\n")
+	if err != nil || !handled {
+		t.Fatalf("CodeHTML handled=%v err=%v", handled, err)
+	}
+	if !strings.Contains(html, `class="chroma"`) {
+		t.Errorf("no .chroma wrapper: %q", html)
+	}
+	if !strings.Contains(html, `<span class="kn">`) {
+		t.Errorf("keyword not class-highlighted: %q", html)
+	}
+	if strings.Contains(html, "style=") {
+		t.Errorf("inline styles leaked into class mode: %q", html)
+	}
+}
+
+func TestCodeHTMLDiffClasses(t *testing.T) {
+	patch := "--- a/f\n+++ b/f\n@@ -1 +1 @@\n-old\n+new\n"
+	html, handled, err := CodeHTML("x.diff", patch)
+	if err != nil || !handled {
+		t.Fatalf("CodeHTML diff handled=%v err=%v", handled, err)
+	}
+	for _, cls := range []string{`class="gd"`, `class="gi"`} {
+		if !strings.Contains(html, cls) {
+			t.Errorf("diff missing %s: %q", cls, html)
+		}
+	}
+}
+
+func TestChromaCSS(t *testing.T) {
+	css, err := ChromaCSS()
+	if err != nil {
+		t.Fatalf("ChromaCSS: %v", err)
+	}
+	style := string(css)
+	if !strings.Contains(style, ".chroma .k") {
+		t.Errorf("no keyword rule in CSS")
+	}
+	if !strings.Contains(style, "#a5aef0") {
+		t.Errorf("palette color missing from CSS")
+	}
+	for _, leaked := range []string{"#66d9ef", "#a6e22e", "#ae81ff"} {
+		if strings.Contains(style, leaked) {
+			t.Errorf("off-palette monokai color %s leaked into CSS", leaked)
+		}
+	}
+}
+
+func TestCodeHTMLUnknownExt(t *testing.T) {
+	if _, handled, err := CodeHTML("file.unknownext", "x"); handled || err != nil {
+		t.Errorf("handled=%v err=%v, want false,nil", handled, err)
+	}
+}
diff --git a/internal/web/auth.go b/internal/web/auth.go
new file mode 100644
index 0000000..87eceed
--- /dev/null
+++ b/internal/web/auth.go
@@ -0,0 +1,137 @@
+package web
+
+import (
+	"errors"
+	"log"
+	"net/http"
+	"time"
+
+	"git.josie-c.com/josie/simplegit/internal/auth"
+	"git.josie-c.com/josie/simplegit/internal/db"
+)
+
+// loginFormMax caps the login POST body; credentials are always tiny.
+const loginFormMax = 1 << 16
+
+type repoItem struct {
+	Owner       string
+	Name        string
+	Description string
+	Visibility  string
+}
+
+type homeData struct {
+	Username string
+	Repos    []repoItem
+}
+
+// repoItems projects a repo listing for a page; owner filters to one
+// account's repos ("" keeps everything).
+func repoItems(repos []db.RepoView, owner string) []repoItem {
+	var items []repoItem
+	for _, repo := range repos {
+		if owner != "" && repo.OwnerName != owner {
+			continue
+		}
+		items = append(items, repoItem{
+			Owner: repo.OwnerName, Name: repo.Name,
+			Description: repo.Description, Visibility: repo.Visibility,
+		})
+	}
+	return items
+}
+
+func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
+	// "GET /" is also the mux catch-all, so serve only the root here;
+	// repo browsing registers its own patterns.
+	if r.URL.Path != "/" {
+		http.NotFound(w, r)
+		return
+	}
+	user := currentUser(r)
+	data := homeData{}
+	var viewerID int64
+	if user != nil {
+		data.Username = user.Username
+		viewerID = user.ID
+	}
+	repos, err := db.ListRepos(s.database, viewerID)
+	if err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	data.Repos = repoItems(repos, "")
+	s.render(w, "home.html", http.StatusOK, data)
+}
+
+func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
+	s.render(w, "login.html", http.StatusOK, pageData{})
+}
+
+// handleLogin authenticates with the stored bcrypt hash, minting a fresh
+// random session on success. Only failed attempts consume the per-IP budget
+// (refused outright once the window is full), so a failure spray can never
+// lock out a correct password; a dummy bcrypt runs on the unknown-user path
+// so all failures cost the same.
+func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
+	ip := clientIP(r)
+	r.Body = http.MaxBytesReader(w, r.Body, loginFormMax)
+	if err := r.ParseForm(); err != nil {
+		http.Error(w, "bad form", http.StatusBadRequest)
+		return
+	}
+	username := r.FormValue("username")
+	password := r.FormValue("password")
+
+	fail := func() {
+		if !s.logins.allow(ip) {
+			http.Error(w, "too many login attempts; try again later", http.StatusTooManyRequests)
+			return
+		}
+		s.render(w, "login.html", http.StatusUnauthorized,
+			pageData{Username: username, Error: "invalid username or password"})
+	}
+	user, err := db.GetUserByName(s.database, username)
+	if errors.Is(err, db.ErrNotFound) {
+		// Keep the response timing uniform with the wrong-password path.
+		_, _ = auth.HashPassword(password)
+		fail()
+		return
+	}
+	if err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	if !auth.CheckPassword(user.PasswordHash, password) {
+		fail()
+		return
+	}
+
+	token, err := auth.NewToken()
+	if err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	// Opportunistic housekeeping: sessions only leave the table at logout,
+	// so without this the expired rows would accumulate forever.
+	if err := db.DeleteExpiredSessions(s.database); err != nil {
+		log.Printf("web: login purge sessions: %v", err)
+	}
+	if err := db.CreateSession(s.database, token, user.ID, time.Now().Add(sessionDuration).Unix()); err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	s.logins.reset(ip)
+	http.SetCookie(w, s.sessionCookie(token, sessionDuration))
+	http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
+	if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" {
+		if err := db.DeleteSession(s.database, cookie.Value); err != nil {
+			log.Printf("web: logout: %v", err)
+		}
+	}
+	http.SetCookie(w, s.sessionCookie("", -1))
+	http.Redirect(w, r, "/login", http.StatusSeeOther)
+}
diff --git a/internal/web/comments.go b/internal/web/comments.go
new file mode 100644
index 0000000..afb713d
--- /dev/null
+++ b/internal/web/comments.go
@@ -0,0 +1,51 @@
+package web
+
+import (
+	"database/sql"
+
+	"git.josie-c.com/josie/simplegit/internal/db"
+)
+
+// commentRow is the projection of an issue or PR comment row onto the
+// shared comment machinery; each store type gets a one-line ToRow.
+type commentRow struct {
+	ID          int64
+	Body        string
+	Pending     bool
+	AuthorID    sql.NullInt64
+	AuthorName  string
+	AuthorEmail string
+	CreatedAt   int64
+}
+
+func issueCommentRow(c db.IssueComment) commentRow {
+	return commentRow{c.ID, c.Body, c.Pending, c.AuthorID, c.AuthorName, c.AuthorEmail, c.CreatedAt}
+}
+
+func pullCommentRow(c db.PullComment) commentRow {
+	return commentRow{c.ID, c.Body, c.Pending, c.AuthorID, c.AuthorName, c.AuthorEmail, c.CreatedAt}
+}
+
+// commentViews renders comment rows into views. ownerID marks the owner
+// badge; ownerView decides whether pending rows are shown at all.
+func commentViews[T any](items []T, ownerID int64, ownerView bool, base string, row func(T) commentRow) []commentView {
+	var views []commentView
+	for _, item := range items {
+		c := row(item)
+		if c.Pending && !ownerView {
+			continue
+		}
+		views = append(views, commentView{
+			ID:            c.ID,
+			BodyHTML:      markdownHTML(c.Body),
+			Pending:       c.Pending,
+			Author:        guestAuthorName(c.AuthorName),
+			AuthorIsOwner: c.AuthorID.Valid && c.AuthorID.Int64 == ownerID,
+			AuthorEmail:   c.AuthorEmail,
+			Created:       issuedAt(c.CreatedAt),
+			IsOwner:       ownerView,
+			Base:          base,
+		})
+	}
+	return views
+}
diff --git a/internal/web/commits.go b/internal/web/commits.go
new file mode 100644
index 0000000..32e1706
--- /dev/null
+++ b/internal/web/commits.go
@@ -0,0 +1,157 @@
+package web
+
+import (
+	"html/template"
+	"net/http"
+	"regexp"
+	"strings"
+
+	"git.josie-c.com/josie/simplegit/internal/git"
+	"git.josie-c.com/josie/simplegit/internal/render"
+)
+
+const commitsPerPage = 50
+
+var shaPattern = regexp.MustCompile(`^[0-9a-fA-F]{4,40}$`)
+
+type commitRow struct {
+	SHA     string // short, 7 chars
+	Href    string
+	Subject string
+	Author  string
+	Date    string // trimmed ISO timestamp
+}
+
+type commitsData struct {
+	navData
+	Ref     string
+	Commits []commitRow
+}
+
+type commitData struct {
+	navData
+	SHA      string
+	Subject  string
+	Body     string
+	Author   string
+	Email    string
+	Date     string
+	Parents  []commitRow
+	TreeHref string
+	DiffHTML template.HTML
+	Notice   string
+}
+
+func (s *Server) handleCommits(w http.ResponseWriter, r *http.Request) {
+	repo, repoPath, user, ok := s.resolveRepo(w, r)
+	if !ok {
+		return
+	}
+	owner := r.PathValue("user")
+	ref := strings.Trim(r.PathValue("ref"), "/")
+	if ref == "" {
+		ref = repo.DefaultBranch
+	}
+	if !validRef(ref) {
+		http.NotFound(w, r)
+		return
+	}
+	exists, err := git.RefExists(repoPath, ref)
+	if err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	if !exists {
+		http.NotFound(w, r)
+		return
+	}
+	base := "/" + owner + "/" + repo.Name
+
+	log, err := git.Log(repoPath, ref, commitsPerPage)
+	if err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	data := commitsData{navData: nav(r, repo, user, "code"), Ref: ref}
+	for _, c := range log {
+		data.Commits = append(data.Commits, commitRow{
+			SHA:     shortSHA(c.SHA),
+			Href:    base + "/commit/" + c.SHA,
+			Subject: c.Subject,
+			Author:  c.Author,
+			Date:    trimDate(c.Date),
+		})
+	}
+	s.render(w, "commits.html", http.StatusOK, data)
+}
+
+func (s *Server) handleCommit(w http.ResponseWriter, r *http.Request) {
+	repo, repoPath, user, ok := s.resolveRepo(w, r)
+	if !ok {
+		return
+	}
+	owner := r.PathValue("user")
+	sha := r.PathValue("sha")
+	if !shaPattern.MatchString(sha) {
+		http.NotFound(w, r)
+		return
+	}
+	base := "/" + owner + "/" + repo.Name
+
+	c, err := git.CommitAt(repoPath, sha)
+	if err != nil {
+		http.NotFound(w, r)
+		return
+	}
+	data := commitData{
+		navData: nav(r, repo, user, "code"),
+		SHA:     c.SHA, Subject: c.Subject,
+		Body: c.Body, Author: c.Author, Email: c.Email, Date: trimDate(c.Date),
+		TreeHref: base + "/tree/" + c.SHA + "/",
+	}
+	for _, p := range c.Parents {
+		if pc, err := git.CommitAt(repoPath, p); err == nil {
+			data.Parents = append(data.Parents, commitRow{
+				SHA: shortSHA(p), Href: base + "/commit/" + p, Subject: pc.Subject,
+			})
+		} else {
+			data.Parents = append(data.Parents, commitRow{SHA: shortSHA(p), Href: base + "/commit/" + p})
+		}
+	}
+
+	patch, err := git.ShowPatch(repoPath, c.SHA, maxDiffBytes)
+	if err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	if len(patch) > 0 {
+		data.DiffHTML, data.Notice = renderDiffHTML(patch)
+	}
+	s.render(w, "commit.html", http.StatusOK, data)
+}
+
+// renderDiffHTML caps a patch at maxDiffBytes and renders it highlighted,
+// falling back to an escaped <pre>; the notice is set when truncated.
+// Shared by the commit view and the PR diff.
+func renderDiffHTML(patch []byte) (template.HTML, string) {
+	notice := ""
+	if len(patch) > maxDiffBytes {
+		patch = patch[:maxDiffBytes]
+		notice = diffTruncatedNotice
+	}
+	if html, handled, err := render.CodeHTML(diffLexeme, string(patch)); err == nil && handled {
+		return template.HTML(html), notice
+	}
+	return template.HTML("<pre>" + template.HTMLEscapeString(string(patch)) + "</pre>"), notice
+}
+
+func shortSHA(sha string) string {
+	return sha[:min(len(sha), 7)]
+}
+
+func trimDate(iso string) string {
+	if len(iso) >= 16 {
+		return iso[:16]
+	}
+	return iso
+}
diff --git a/internal/web/commits_test.go b/internal/web/commits_test.go
new file mode 100644
index 0000000..7d3d66c
--- /dev/null
+++ b/internal/web/commits_test.go
@@ -0,0 +1,99 @@
+package web
+
+import (
+	"net/http"
+	"strings"
+	"testing"
+)
+
+func TestCommitsListAndPrivateGate(t *testing.T) {
+	httpServer, _, dataDir := newTestServer(t)
+	loggedIn := newLoggedInClient(t, httpServer)
+	createRepo(t, loggedIn, httpServer, "pub", "public")
+	createRepo(t, loggedIn, httpServer, "sec", "private")
+	seedFiles(t, dataDir, "pub")
+	seedFiles(t, dataDir, "sec")
+
+	resp, err := (&http.Client{}).Get(httpServer.URL + "/josie/pub/commits")
+	if err != nil {
+		t.Fatalf("GET commits: %v", err)
+	}
+	body := readAll(t, resp)
+	if resp.StatusCode != http.StatusOK || !strings.Contains(body, "seed") {
+		t.Fatalf("commits page = %d, want 200 with subject: %q", resp.StatusCode, body)
+	}
+	if !strings.Contains(body, "/josie/pub/commit/") {
+		t.Error("no commit links")
+	}
+
+	resp, err = noFollowClient().Get(httpServer.URL + "/josie/sec/commits")
+	if err != nil {
+		t.Fatalf("GET private commits: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusNotFound {
+		t.Errorf("anonymous private commits = %d, want 404 (no existence oracle)", resp.StatusCode)
+	}
+
+	resp, err = (&http.Client{}).Get(httpServer.URL + "/josie/pub/commits/nosuchbranch")
+	if err != nil {
+		t.Fatalf("GET bad ref: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusNotFound {
+		t.Errorf("bad ref = %d, want 404", resp.StatusCode)
+	}
+}
+
+func TestCommitView(t *testing.T) {
+	httpServer, _, dataDir := newTestServer(t)
+	loggedIn := newLoggedInClient(t, httpServer)
+	createRepo(t, loggedIn, httpServer, "pub", "public")
+	seedFiles(t, dataDir, "pub")
+
+	resp, err := (&http.Client{}).Get(httpServer.URL + "/josie/pub/commits")
+	if err != nil {
+		t.Fatalf("GET commits: %v", err)
+	}
+	listing := readAll(t, resp)
+	start := strings.Index(listing, "/josie/pub/commit/")
+	if start < 0 {
+		t.Fatal("no commit link to follow")
+	}
+	rest := listing[start:]
+	sha := rest[len("/josie/pub/commit/"):]
+	sha = sha[:strings.IndexByte(sha, '"')]
+
+	resp, err = (&http.Client{}).Get(httpServer.URL + sha2href(sha))
+	if err != nil {
+		t.Fatalf("GET commit: %v", err)
+	}
+	body := readAll(t, resp)
+	if resp.StatusCode != http.StatusOK {
+		t.Fatalf("commit view = %d", resp.StatusCode)
+	}
+	for _, want := range []string{"seed", sha, "README", "browse files at this commit"} {
+		if !strings.Contains(body, want) {
+			t.Errorf("commit view lacks %q", want)
+		}
+	}
+
+	resp, err = (&http.Client{}).Get(httpServer.URL + "/josie/pub/commit/notahex!")
+	if err != nil {
+		t.Fatalf("GET bad sha: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusNotFound {
+		t.Errorf("bad sha = %d, want 404", resp.StatusCode)
+	}
+	resp, err = (&http.Client{}).Get(httpServer.URL + "/josie/pub/commit/0000000000000000000000000000000000000000")
+	if err != nil {
+		t.Fatalf("GET missing sha: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusNotFound {
+		t.Errorf("missing sha = %d, want 404", resp.StatusCode)
+	}
+}
+
+func sha2href(sha string) string { return "/josie/pub/commit/" + sha }
diff --git a/internal/web/consts.go b/internal/web/consts.go
new file mode 100644
index 0000000..ae59424
--- /dev/null
+++ b/internal/web/consts.go
@@ -0,0 +1,44 @@
+package web
+
+import (
+	"net/http"
+	"time"
+)
+
+// Shared limits, states, and literals for the web handlers, so issues.go,
+// pulls.go, comments.go, and the settings pages share one home.
+const (
+	maxIssueBody = 64 << 10
+	issueFormMax = 1 << 20
+	maxTitleLen  = 300
+	maxNameLen   = 100
+	maxEmailLen  = 200
+
+	guestThreadCap  = 10
+	guestCommentCap = 60
+	guestWindow     = time.Hour
+
+	tokenLabelMax = 100
+
+	maxDiffBytes        = 2 << 20
+	diffTruncatedNotice = "Diff is larger than 2 MB; truncated."
+	diffLexeme          = "x.diff"
+
+	rawLimit = 16 << 20
+
+	visibilityPublic  = "public"
+	visibilityPrivate = "private"
+
+	stateOpen   = "open"
+	stateClosed = "closed"
+	stateMerged = "merged"
+
+	submittedParam = "submitted"
+	honeypotField  = "website"
+	showAll        = "all"
+)
+
+// submitted reports whether the ?submitted=1 confirmation flag is set.
+func submitted(r *http.Request) bool {
+	return r.URL.Query().Get(submittedParam) == "1"
+}
diff --git a/internal/web/git.go b/internal/web/git.go
new file mode 100644
index 0000000..3332ee8
--- /dev/null
+++ b/internal/web/git.go
@@ -0,0 +1,158 @@
+package web
+
+import (
+	"errors"
+	"log"
+	"net/http"
+	"path/filepath"
+	"strings"
+
+	"git.josie-c.com/josie/simplegit/internal/auth"
+	"git.josie-c.com/josie/simplegit/internal/db"
+	"git.josie-c.com/josie/simplegit/internal/git"
+)
+
+// isOwner reports whether user is the repository's owner.
+func isOwner(user *db.User, repo db.Repo) bool {
+	return user != nil && user.ID == repo.OwnerID
+}
+
+// canAccess reports whether user may perform the git operation: reads need
+// a public repo or ownership, writes always need ownership.
+func canAccess(user *db.User, repo db.Repo, write bool) bool {
+	if write {
+		return isOwner(user, repo)
+	}
+	return repo.Visibility == visibilityPublic || isOwner(user, repo)
+}
+
+// gitAuth resolves who the remote git client is for this request and
+// authorizes it. Order: browser session, then HTTP basic (password check
+// now, git tokens in M2.3); anonymous is allowed only to read public repos.
+// On failure it writes the response and returns ok=false.
+func (s *Server) gitAuth(w http.ResponseWriter, r *http.Request, repo db.Repo, write bool) (string, bool) {
+	if user := currentUser(r); user != nil {
+		if !canAccess(user, repo, write) {
+			http.Error(w, "forbidden", http.StatusForbidden)
+			return "", false
+		}
+		return user.Username, true
+	}
+	if username, secret, supplied := r.BasicAuth(); supplied {
+		user, ok := s.authenticateSecret(username, secret)
+		if !ok {
+			// Basic-auth failures share the login budget: the web password
+			// is a git credential, so guessing here is guessing there.
+			if !s.logins.allow(clientIP(r)) {
+				http.Error(w, "too many failed authentications; try again later", http.StatusTooManyRequests)
+				return "", false
+			}
+			s.gitChallenge(w)
+			return "", false
+		}
+		s.logins.reset(clientIP(r))
+		if !canAccess(user, repo, write) {
+			http.Error(w, "forbidden", http.StatusForbidden)
+			return "", false
+		}
+		return user.Username, true
+	}
+	if !write && repo.Visibility == visibilityPublic {
+		return "", true
+	}
+	s.gitChallenge(w)
+	return "", false
+}
+
+func (s *Server) gitChallenge(w http.ResponseWriter) {
+	w.Header().Set("WWW-Authenticate", `Basic realm="simplegit"`)
+	http.Error(w, "authentication required", http.StatusUnauthorized)
+}
+
+// authenticateSecret checks a basic-auth username/secret pair against the
+// user's password hash, then against a git token digest. A token used this
+// way is recorded as used.
+func (s *Server) authenticateSecret(username, secret string) (*db.User, bool) {
+	user, err := db.GetUserByName(s.database, username)
+	if err != nil {
+		// Keep the timing flat with the wrong-password path.
+		_, _ = auth.HashPassword(secret)
+		return nil, false
+	}
+	if auth.CheckPassword(user.PasswordHash, secret) {
+		return &user, true
+	}
+	token, err := db.GetTokenByHash(s.database, auth.HashToken(secret))
+	if err != nil || token.UserID != user.ID {
+		return nil, false
+	}
+	_ = db.TouchToken(s.database, token.ID)
+	return &user, true
+}
+
+// gitRepoAndAuth handles the shared preamble: resolve {user}/{repo}.git to
+// a DB row and authenticate the client for the given operation.
+func (s *Server) gitRepoAndAuth(w http.ResponseWriter, r *http.Request, write bool) (string, bool) {
+	owner := r.PathValue("user")
+	repoGit := r.PathValue("repoGit")
+	if !strings.HasSuffix(repoGit, ".git") {
+		http.NotFound(w, r)
+		return "", false
+	}
+	repo, err := db.GetRepoByName(s.database, owner, strings.TrimSuffix(repoGit, ".git"))
+	if errors.Is(err, db.ErrNotFound) {
+		http.NotFound(w, r)
+		return "", false
+	}
+	if err != nil {
+		s.internalError(w, r, err)
+		return "", false
+	}
+	return s.gitAuth(w, r, repo, write)
+}
+
+func (s *Server) serveBackend(w http.ResponseWriter, r *http.Request, remoteUser, service string) {
+	projectRoot := filepath.Join(s.cfg.DataDir, "repos")
+	if err := git.ServeBackend(projectRoot, remoteUser, service, r, w); err != nil {
+		log.Printf("web: http-backend %s: %v", r.URL.Path, err)
+	}
+}
+
+// handleGitRefs serves GET /{user}/{repo}.git/info/refs — the ref
+// advertisement. Only the smart protocol is offered: the service is either
+// upload-pack (read) or receive-pack (push, owner-only).
+func (s *Server) handleGitRefs(w http.ResponseWriter, r *http.Request) {
+	service := r.URL.Query().Get("service")
+	if service != "git-upload-pack" && service != "git-receive-pack" {
+		if _, ok := s.gitRepoAndAuth(w, r, false); !ok {
+			return
+		}
+		http.Error(w, "smart HTTP only: a service parameter is required", http.StatusForbidden)
+		return
+	}
+	remoteUser, ok := s.gitRepoAndAuth(w, r, service == "git-receive-pack")
+	if !ok {
+		return
+	}
+	s.serveBackend(w, r, remoteUser, service)
+}
+
+// handleGitUploadPack serves the POST body half of a clone/fetch.
+func (s *Server) handleGitUploadPack(w http.ResponseWriter, r *http.Request) {
+	remoteUser, ok := s.gitRepoAndAuth(w, r, false)
+	if !ok {
+		return
+	}
+	s.serveBackend(w, r, remoteUser, "git-upload-pack")
+}
+
+// handleGitReceivePack serves a push: owner-only, then http-backend with
+// REMOTE_USER set, which is what lets git allow receive-pack (http.receivepack
+// stays unset on purpose — the authorization decision lives here in Go).
+func (s *Server) handleGitReceivePack(w http.ResponseWriter, r *http.Request) {
+	remoteUser, ok := s.gitRepoAndAuth(w, r, true)
+	if !ok {
+		return
+	}
+	s.serveBackend(w, r, remoteUser, "git-receive-pack")
+}
diff --git a/internal/web/git_test.go b/internal/web/git_test.go
new file mode 100644
index 0000000..c8fc0f4
--- /dev/null
+++ b/internal/web/git_test.go
@@ -0,0 +1,308 @@
+package web
+
+import (
+	"database/sql"
+	"net/http"
+	"net/http/cookiejar"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"golang.org/x/crypto/bcrypt"
+
+	"git.josie-c.com/josie/simplegit/internal/db"
+)
+
+func addUser(t *testing.T, database *sql.DB, username, password string) {
+	t.Helper()
+	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
+	if err != nil {
+		t.Fatalf("hash password: %v", err)
+	}
+	if _, err := db.CreateUser(database, username, string(hash)); err != nil {
+		t.Fatalf("create user %s: %v", username, err)
+	}
+}
+
+func loginAs(t *testing.T, httpServer *httptest.Server, username, password string) *http.Client {
+	t.Helper()
+	client := &http.Client{Transport: httpServer.Client().Transport}
+	client.Jar, _ = cookiejar.New(nil)
+	resp, err := client.PostForm(httpServer.URL+"/login",
+		url.Values{"username": {username}, "password": {password}})
+	if err != nil {
+		t.Fatalf("POST /login %s: %v", username, err)
+	}
+	if body := readAll(t, resp); !strings.Contains(body, `href="/`+username+`"`) {
+		t.Fatalf("login as %s failed: %q", username, body)
+	}
+	return client
+}
+
+func runGit(t *testing.T, dir string, args ...string) string {
+	t.Helper()
+	cmd := exec.Command("git", args...)
+	cmd.Dir = dir
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		t.Fatalf("git %v: %v: %s", args, err, out)
+	}
+	return string(out)
+}
+
+func createRepo(t *testing.T, client *http.Client, server *httptest.Server, name, visibility string) {
+	t.Helper()
+	resp, err := client.PostForm(server.URL+"/new", url.Values{
+		"name": {name}, "visibility": {visibility},
+	})
+	if err != nil {
+		t.Fatalf("POST /new %s: %v", name, err)
+	}
+	body := readAll(t, resp)
+	if resp.StatusCode != http.StatusOK {
+		t.Fatalf("create %s: status %d body %q", name, resp.StatusCode, body)
+	}
+}
+
+func TestGitPublicCloneAnonymous(t *testing.T) {
+	httpServer, _, _ := newTestServer(t)
+	createRepo(t, newLoggedInClient(t, httpServer), httpServer, "pub", "public")
+
+	resp, err := (&http.Client{}).Get(httpServer.URL + "/josie/pub.git/info/refs?service=git-upload-pack")
+	if err != nil {
+		t.Fatalf("anonymous clone refs: %v", err)
+	}
+	body := readAll(t, resp)
+	if resp.StatusCode != http.StatusOK {
+		t.Errorf("status = %d, want 200: %q", resp.StatusCode, body)
+	}
+	if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "application/x-git-upload-pack-advertisement") {
+		t.Errorf("Content-Type = %q", ct)
+	}
+}
+
+func TestGitPrivateGate(t *testing.T) {
+	httpServer, _, _ := newTestServer(t)
+	createRepo(t, newLoggedInClient(t, httpServer), httpServer, "sec", "private")
+	refsURL := httpServer.URL + "/josie/sec.git/info/refs?service=git-upload-pack"
+
+	resp, err := (&http.Client{}).Get(refsURL)
+	if err != nil {
+		t.Fatalf("anonymous private refs: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusUnauthorized {
+		t.Errorf("anonymous status = %d, want 401", resp.StatusCode)
+	}
+	if !strings.Contains(resp.Header.Get("WWW-Authenticate"), "Basic") {
+		t.Errorf("WWW-Authenticate = %q, want Basic challenge", resp.Header.Get("WWW-Authenticate"))
+	}
+
+	badReq, _ := http.NewRequest("GET", refsURL, nil)
+	badReq.SetBasicAuth("josie", "wrong")
+	badResp, err := (&http.Client{}).Do(badReq)
+	if err != nil {
+		t.Fatalf("bad basic refs: %v", err)
+	}
+	readAll(t, badResp)
+	if badResp.StatusCode != http.StatusUnauthorized {
+		t.Errorf("bad basic status = %d, want 401", badResp.StatusCode)
+	}
+
+	goodReq, _ := http.NewRequest("GET", refsURL, nil)
+	goodReq.SetBasicAuth("josie", "hunter2")
+	goodResp, err := (&http.Client{}).Do(goodReq)
+	if err != nil {
+		t.Fatalf("good basic refs: %v", err)
+	}
+	readAll(t, goodResp)
+	if goodResp.StatusCode != http.StatusOK {
+		t.Errorf("basic-auth status = %d, want 200", goodResp.StatusCode)
+	}
+
+	signedIn := newLoggedInClient(t, httpServer)
+	resp, err = signedIn.Get(refsURL)
+	if err != nil {
+		t.Fatalf("session refs: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusOK {
+		t.Errorf("session-cookie status = %d, want 200", resp.StatusCode)
+	}
+}
+
+func TestGitUnknownPaths(t *testing.T) {
+	httpServer, _, _ := newTestServer(t)
+	for _, path := range []string{
+		"/josie/nope.git/info/refs?service=git-upload-pack",
+		"/other/pub.git/info/refs?service=git-upload-pack",
+		"/josie/pub/info/refs?service=git-upload-pack",
+		"/josie/pub.git/not-a-service",
+	} {
+		resp, err := (&http.Client{}).Get(httpServer.URL + path)
+		if err != nil {
+			t.Fatalf("GET %s: %v", path, err)
+		}
+		readAll(t, resp)
+		if resp.StatusCode != http.StatusNotFound {
+			t.Errorf("GET %s = %d, want 404", path, resp.StatusCode)
+		}
+	}
+}
+
+func TestGitSmartOnly(t *testing.T) {
+	httpServer, _, _ := newTestServer(t)
+	createRepo(t, newLoggedInClient(t, httpServer), httpServer, "pub", "public")
+
+	for _, path := range []string{
+		"/josie/pub.git/info/refs",
+		"/josie/pub.git/info/refs?service=nonsense",
+	} {
+		resp, err := (&http.Client{}).Get(httpServer.URL + path)
+		if err != nil {
+			t.Fatalf("GET %s: %v", path, err)
+		}
+		body := readAll(t, resp)
+		if resp.StatusCode != http.StatusForbidden {
+			t.Errorf("GET %s = %d, want 403: %q", path, resp.StatusCode, body)
+		}
+	}
+}
+
+func TestGitReceivePackAuth(t *testing.T) {
+	httpServer, database, _ := newTestServer(t)
+	createRepo(t, newLoggedInClient(t, httpServer), httpServer, "pub", "public")
+	addUser(t, database, "mallory", "pw")
+	refsURL := httpServer.URL + "/josie/pub.git/info/refs?service=git-receive-pack"
+
+	resp, err := (&http.Client{}).Get(refsURL)
+	if err != nil {
+		t.Fatalf("anonymous receive-pack refs: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusUnauthorized {
+		t.Errorf("anonymous status = %d, want 401 challenge", resp.StatusCode)
+	}
+
+	ownerReq, _ := http.NewRequest("GET", refsURL, nil)
+	ownerReq.SetBasicAuth("josie", "hunter2")
+	ownerResp, err := (&http.Client{}).Do(ownerReq)
+	if err != nil {
+		t.Fatalf("owner receive-pack refs: %v", err)
+	}
+	readAll(t, ownerResp)
+	if ownerResp.StatusCode != http.StatusOK {
+		t.Errorf("owner status = %d, want 200", ownerResp.StatusCode)
+	}
+
+	otherReq, _ := http.NewRequest("GET", refsURL, nil)
+	otherReq.SetBasicAuth("mallory", "pw")
+	otherResp, err := (&http.Client{}).Do(otherReq)
+	if err != nil {
+		t.Fatalf("non-owner receive-pack refs: %v", err)
+	}
+	readAll(t, otherResp)
+	if otherResp.StatusCode != http.StatusForbidden {
+		t.Errorf("non-owner status = %d, want 403", otherResp.StatusCode)
+	}
+}
+
+func TestGitRefsPinsAuthorizedService(t *testing.T) {
+	httpServer, database, _ := newTestServer(t)
+	createRepo(t, newLoggedInClient(t, httpServer), httpServer, "pub", "public")
+	addUser(t, database, "mallory", "pw")
+
+	refsURL := httpServer.URL + "/josie/pub.git/info/refs?service=git-upload-pack&service=git-receive-pack"
+	req, _ := http.NewRequest("GET", refsURL, nil)
+	req.SetBasicAuth("mallory", "pw")
+	resp, err := (&http.Client{}).Do(req)
+	if err != nil {
+		t.Fatalf("dup-service refs: %v", err)
+	}
+	readAll(t, resp)
+	if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "git-upload-pack-advertisement") {
+		t.Errorf("Content-Type = %q, want upload-pack advertisement (read auth pins the service)", ct)
+	}
+}
+
+func TestGitPushOwner(t *testing.T) {
+	httpServer, _, dataDir := newTestServer(t)
+	createRepo(t, newLoggedInClient(t, httpServer), httpServer, "pub", "public")
+
+	u, err := url.Parse(httpServer.URL)
+	if err != nil {
+		t.Fatalf("parse server URL: %v", err)
+	}
+	repoURL := "http://josie:hunter2@" + u.Host + "/josie/pub.git"
+
+	work := filepath.Join(t.TempDir(), "work")
+	runGit(t, "", "-c", "credential.helper=", "clone", "-q", repoURL, work)
+	if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("hello\n"), 0o644); err != nil {
+		t.Fatalf("write file: %v", err)
+	}
+	runGit(t, work, "add", ".")
+	runGit(t, work, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "first")
+	runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
+
+	bare := filepath.Join(dataDir, "repos", "josie", "pub.git")
+	if out, err := exec.Command("git", "-C", bare, "rev-parse", "--verify", "refs/heads/main").CombinedOutput(); err != nil {
+		t.Fatalf("pushed ref missing: %v: %s", err, out)
+	}
+}
+
+func TestGitPushNonOwnerDenied(t *testing.T) {
+	httpServer, database, _ := newTestServer(t)
+	createRepo(t, newLoggedInClient(t, httpServer), httpServer, "pub", "public")
+	addUser(t, database, "mallory", "pw")
+
+	req, _ := http.NewRequest("POST", httpServer.URL+"/josie/pub.git/git-receive-pack",
+		strings.NewReader("x"))
+	req.SetBasicAuth("mallory", "pw")
+	req.Header.Set("Content-Type", "application/x-git-receive-pack-request")
+	resp, err := (&http.Client{}).Do(req)
+	if err != nil {
+		t.Fatalf("POST receive-pack as non-owner: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusForbidden {
+		t.Errorf("non-owner push = %d, want 403", resp.StatusCode)
+	}
+}
+
+// Basic-auth failures on the git endpoints share the login budget: the web
+// password is a git credential, so guessing here is throttled like /login.
+func TestGitBasicAuthRateLimited(t *testing.T) {
+	httpServer, _, _ := newTestServer(t)
+	createRepo(t, newLoggedInClient(t, httpServer), httpServer, "sec", "private")
+	refsURL := httpServer.URL + "/josie/sec.git/info/refs?service=git-receive-pack"
+
+	for i := 0; i < loginAttempts; i++ {
+		req, err := http.NewRequest("GET", refsURL, nil)
+		if err != nil {
+			t.Fatalf("request %d: %v", i+1, err)
+		}
+		req.SetBasicAuth("josie", "wrong")
+		resp, err := (&http.Client{}).Do(req)
+		if err != nil {
+			t.Fatalf("attempt %d: %v", i+1, err)
+		}
+		readAll(t, resp)
+		if resp.StatusCode != http.StatusUnauthorized {
+			t.Fatalf("attempt %d = %d, want 401", i+1, resp.StatusCode)
+		}
+	}
+	req, _ := http.NewRequest("GET", refsURL, nil)
+	req.SetBasicAuth("josie", "wrong")
+	resp, err := (&http.Client{}).Do(req)
+	if err != nil {
+		t.Fatalf("limited attempt: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusTooManyRequests {
+		t.Errorf("attempt %d = %d, want 429", loginAttempts+1, resp.StatusCode)
+	}
+}
diff --git a/internal/web/issues.go b/internal/web/issues.go
new file mode 100644
index 0000000..0b68343
--- /dev/null
+++ b/internal/web/issues.go
@@ -0,0 +1,599 @@
+package web
+
+import (
+	"database/sql"
+	"errors"
+	"html/template"
+	"net"
+	"net/http"
+	"slices"
+	"strconv"
+	"strings"
+	"time"
+	"unicode/utf8"
+
+	"git.josie-c.com/josie/simplegit/internal/db"
+	"git.josie-c.com/josie/simplegit/internal/render"
+)
+
+// repoPage resolves {user}/{repo} for the HTML pages with the site-wide
+// visibility gate. On failure it has written the response.
+func (s *Server) repoPage(w http.ResponseWriter, r *http.Request) (db.Repo, *db.User, bool) {
+	repo, err := db.GetRepoByName(s.database, r.PathValue("user"), r.PathValue("repo"))
+	if errors.Is(err, db.ErrNotFound) {
+		http.NotFound(w, r)
+		return db.Repo{}, nil, false
+	}
+	if err != nil {
+		s.internalError(w, r, err)
+		return db.Repo{}, nil, false
+	}
+	user := currentUser(r)
+	// Private repos 404 for everyone but the owner, so existence is not an
+	// anonymous oracle.
+	if repo.Visibility != visibilityPublic && !isOwner(user, repo) {
+		http.NotFound(w, r)
+		return db.Repo{}, nil, false
+	}
+	return repo, user, true
+}
+
+// canWriteContent reports whether the caller may author issues/comments:
+// the owner always, a guest only on a public repo.
+func canWriteContent(user *db.User, repo db.Repo) bool {
+	return isOwner(user, repo) || repo.Visibility == visibilityPublic
+}
+
+func issueBasePath(r *http.Request) string {
+	return "/" + r.PathValue("user") + "/" + r.PathValue("repo") + "/issues"
+}
+
+func issueNumber(r *http.Request) (int64, bool) {
+	number, err := strconv.ParseInt(r.PathValue("number"), 10, 64)
+	return number, err == nil && number > 0
+}
+
+// threadHref builds an issue/PR URL from its base path and number.
+func threadHref(base string, number int64) string {
+	return base + "/" + strconv.FormatInt(number, 10)
+}
+
+// showFilter normalizes the ?show= list filter: anything not in allowed
+// falls back to "open". state is "" for showAll, so list queries skip the
+// state predicate.
+func showFilter(r *http.Request, allowed ...string) (show, state string) {
+	show = r.URL.Query().Get("show")
+	if !slices.Contains(allowed, show) {
+		show = stateOpen
+	}
+	if show == showAll {
+		return show, ""
+	}
+	return show, show
+}
+
+// pathID parses the {id} path value (a comment or asset id).
+func pathID(r *http.Request) (int64, bool) {
+	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+	return id, err == nil && id > 0
+}
+
+func isHTMX(r *http.Request) bool {
+	return r.Header.Get("HX-Request") == "true"
+}
+
+func clientIP(r *http.Request) string {
+	host, _, err := net.SplitHostPort(r.RemoteAddr)
+	if err != nil {
+		return r.RemoteAddr
+	}
+	return host
+}
+
+func issuedAt(epoch int64) string {
+	return time.Unix(epoch, 0).UTC().Format("2006-01-02 15:04")
+}
+
+func guestAuthorName(name string) string {
+	if name == "" {
+		return "Anonymous"
+	}
+	return name
+}
+
+// truncate cuts s to max bytes without splitting a UTF-8 rune.
+func truncate(s string, max int) string {
+	if len(s) <= max {
+		return s
+	}
+	for max > 0 && !utf8.RuneStart(s[max]) {
+		max--
+	}
+	return s[:max]
+}
+
+// formText reads a form value, trimmed and capped at max bytes.
+func formText(r *http.Request, name string, max int) string {
+	return truncate(strings.TrimSpace(r.FormValue(name)), max)
+}
+
+func markdownHTML(source string) template.HTML {
+	html, err := render.Markdown([]byte(source))
+	if err != nil {
+		return template.HTML("<pre>" + template.HTMLEscapeString(source) + "</pre>")
+	}
+	return template.HTML(html)
+}
+
+// issueIdentity returns the stored author (id 0 for a guest) for a new row.
+// An authenticated author is attributed to their account; a guest supplies a
+// self-claimed display name and optional email. A guest cannot claim the
+// repo owner's name — an approved row would otherwise read as the owner's.
+func issueIdentity(r *http.Request, user *db.User, ownerName string) (int64, string, string) {
+	if user != nil {
+		return user.ID, user.Username, ""
+	}
+	name := formText(r, "author_name", maxNameLen)
+	if strings.EqualFold(name, ownerName) {
+		name = ""
+	}
+	email := formText(r, "author_email", maxEmailLen)
+	return 0, guestAuthorName(name), email
+}
+
+type issueListRow struct {
+	Number        int64
+	Title         string
+	State         string
+	Pending       bool
+	Author        string
+	AuthorIsOwner bool
+	Created       string
+	Href          string
+}
+
+type issueListData struct {
+	navData
+	Show         string
+	IsOwner      bool
+	CanWrite     bool
+	PendingCount int
+	Issues       []issueListRow
+}
+
+// handleIssues renders the issue list. Anonymous visitors of a public repo
+// see non-pending issues; the owner's ?show=owner view is the pending
+// moderation queue.
+func (s *Server) handleIssues(w http.ResponseWriter, r *http.Request) {
+	repo, user, ok := s.repoPage(w, r)
+	if !ok {
+		return
+	}
+	ownerView := isOwner(user, repo)
+	show, state := showFilter(r, stateClosed, showAll)
+
+	issues, err := db.ListIssues(s.database, repo.ID, ownerView, state)
+	if err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	data := issueListData{
+		navData: nav(r, repo, user, "issues"), Show: show,
+		IsOwner: ownerView, CanWrite: canWriteContent(user, repo),
+	}
+	if ownerView {
+		if data.PendingCount, err = db.CountPending(s.database, repo.ID); err != nil {
+			s.internalError(w, r, err)
+			return
+		}
+	}
+	for _, issue := range issues {
+		data.Issues = append(data.Issues, issueListRow{
+			Number:  issue.Number,
+			Title:   issue.Title,
+			State:   issue.State,
+			Pending: issue.Pending,
+			Author:  guestAuthorName(issue.AuthorName),
+			AuthorIsOwner: issue.AuthorID.Valid &&
+				issue.AuthorID.Int64 == repo.OwnerID,
+			Created: issuedAt(issue.CreatedAt),
+			Href:    threadHref(issueBasePath(r), issue.Number),
+		})
+	}
+	s.render(w, "issues.html", http.StatusOK, data)
+}
+
+type issueNewData struct {
+	navData
+	IsOwner bool
+	Title   string
+	Body    string
+	Error   string
+}
+
+// handleIssueNewForm renders the issue form. Guests may file on public repos.
+func (s *Server) handleIssueNewForm(w http.ResponseWriter, r *http.Request) {
+	repo, user, ok := s.repoPage(w, r)
+	if !ok {
+		return
+	}
+	if !canWriteContent(user, repo) {
+		http.NotFound(w, r)
+		return
+	}
+	s.render(w, "issue_new.html", http.StatusOK, issueNewData{
+		navData: nav(r, repo, user, "issues"),
+		IsOwner: isOwner(user, repo),
+	})
+}
+
+// handleCreateIssue stores a new issue. The owner's issue is published
+// immediately; a guest's is pending owner moderation.
+func (s *Server) handleCreateIssue(w http.ResponseWriter, r *http.Request) {
+	repo, user, ok := s.repoPage(w, r)
+	if !ok {
+		return
+	}
+	if !canWriteContent(user, repo) {
+		http.NotFound(w, r)
+		return
+	}
+	trusted := isOwner(user, repo)
+	if !trusted && !s.guestThreads.allow(clientIP(r)) {
+		http.Error(w, "too many issues filed; try again later", http.StatusTooManyRequests)
+		return
+	}
+
+	r.Body = http.MaxBytesReader(w, r.Body, issueFormMax)
+	if err := r.ParseForm(); err != nil {
+		http.Error(w, "bad form", http.StatusBadRequest)
+		return
+	}
+	title := formText(r, "title", maxTitleLen)
+	body := formText(r, "body", maxIssueBody)
+	fail := func(msg string) {
+		data := issueNewData{
+			navData: nav(r, repo, user, "issues"), IsOwner: trusted,
+			Title: title, Body: body, Error: msg,
+		}
+		s.render(w, "issue_new.html", http.StatusUnprocessableEntity, data)
+	}
+	if title == "" {
+		fail("a title is required")
+		return
+	}
+	if !trusted && strings.TrimSpace(r.FormValue(honeypotField)) != "" {
+		// Honeypot: pretend success, store nothing.
+		http.Redirect(w, r, issueBasePath(r)+"?"+submittedParam+"=1", http.StatusSeeOther)
+		return
+	}
+
+	authorID, authorName, authorEmail := issueIdentity(r, user, r.PathValue("user"))
+	if !trusted {
+		dup, err := db.HasDuplicateIssue(s.database, repo.ID, title, body, authorName, authorEmail)
+		if err != nil {
+			s.internalError(w, r, err)
+			return
+		}
+		if dup {
+			// Identical filing already stored; answer exactly like a fresh
+			// submission so a spammer gets no oracle.
+			s.render(w, "issue_submitted.html", http.StatusOK, nav(r, repo, user, "issues"))
+			return
+		}
+	}
+	issue, err := db.CreateIssue(s.database, repo.ID, title, body, authorID, authorName, authorEmail, !trusted)
+	if err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	if trusted {
+		http.Redirect(w, r, threadHref(issueBasePath(r), issue.Number), http.StatusSeeOther)
+		return
+	}
+	s.render(w, "issue_submitted.html", http.StatusOK, nav(r, repo, user, "issues"))
+}
+
+type commentView struct {
+	ID            int64
+	BodyHTML      template.HTML
+	Pending       bool
+	Author        string
+	AuthorIsOwner bool
+	AuthorEmail   string
+	Created       string
+	IsOwner       bool
+	Base          string
+}
+
+type issueViewData struct {
+	navData
+	Number        int64
+	Title         string
+	State         string
+	Pending       bool
+	Author        string
+	AuthorIsOwner bool
+	AuthorEmail   string
+	Created       string
+	BodyHTML      template.HTML
+	Comments      []commentView
+	IsOwner       bool
+	CanWrite      bool
+	Base          string
+	Submitted     bool
+}
+
+// fetchByNumber loads a thread row by (repo, number), mapping not-found to
+// 404 and anything else to the generic 500 — the preamble every issue/PR
+// handler shares.
+func fetchByNumber[T any](s *Server, w http.ResponseWriter, r *http.Request, repoID, number int64, fetch func(*sql.DB, int64, int64) (T, error)) (T, bool) {
+	var zero T
+	item, err := fetch(s.database, repoID, number)
+	if errors.Is(err, db.ErrNotFound) {
+		http.NotFound(w, r)
+		return zero, false
+	}
+	if err != nil {
+		s.internalError(w, r, err)
+		return zero, false
+	}
+	return item, true
+}
+
+// handleIssueView shows one issue and its comments. A non-owner cannot see a
+// pending issue; pending comments are visible only to the owner.
+func (s *Server) handleIssueView(w http.ResponseWriter, r *http.Request) {
+	repo, user, number, ok := s.repoNumber(w, r)
+	if !ok {
+		return
+	}
+	issue, ok := fetchByNumber(s, w, r, repo.ID, number, db.GetIssueByNumber)
+	if !ok {
+		return
+	}
+	ownerView := isOwner(user, repo)
+	if issue.Pending && !ownerView {
+		http.NotFound(w, r)
+		return
+	}
+	comments, err := db.ListComments(s.database, issue.ID, ownerView)
+	if err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	base := threadHref(issueBasePath(r), number)
+	data := issueViewData{
+		navData: nav(r, repo, user, "issues"), Number: number,
+		Title: issue.Title, State: issue.State, Pending: issue.Pending,
+		Author:        guestAuthorName(issue.AuthorName),
+		AuthorIsOwner: issue.AuthorID.Valid && issue.AuthorID.Int64 == repo.OwnerID,
+		AuthorEmail:   issue.AuthorEmail,
+		Created:       issuedAt(issue.CreatedAt),
+		BodyHTML:      markdownHTML(issue.Body),
+		IsOwner:       ownerView, CanWrite: canWriteContent(user, repo),
+		Base:      base,
+		Submitted: submitted(r),
+	}
+	data.Comments = commentViews(comments, repo.OwnerID, ownerView, base, issueCommentRow)
+	s.render(w, "issue.html", http.StatusOK, data)
+}
+
+type issueStateData struct {
+	Base    string
+	State   string
+	IsOwner bool
+	Pending bool
+}
+
+// handleIssueState closes or reopens an issue (owner-only).
+func (s *Server) handleIssueState(w http.ResponseWriter, r *http.Request, state string) {
+	repo, _, number, ok := s.ownerNumber(w, r)
+	if !ok {
+		return
+	}
+	issue, ok := fetchByNumber(s, w, r, repo.ID, number, db.GetIssueByNumber)
+	if !ok {
+		return
+	}
+	if err := db.SetIssueState(s.database, repo.ID, number, state); err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	base := threadHref(issueBasePath(r), number)
+	if isHTMX(r) {
+		s.renderFragment(w, "issue.html", "state", issueStateData{
+			Base: base, State: state, IsOwner: true, Pending: issue.Pending,
+		})
+		return
+	}
+	http.Redirect(w, r, base, http.StatusSeeOther)
+}
+
+// handleCreateComment stores an issue comment through the shared comment
+// pipeline: owner comments publish immediately, guest comments are pending
+// moderation and are never echoed back as a visible comment.
+func (s *Server) handleCreateComment(w http.ResponseWriter, r *http.Request) {
+	repo, user, number, ok := s.repoNumber(w, r)
+	if !ok {
+		return
+	}
+	base := threadHref(issueBasePath(r), number)
+	issue, ok := fetchByNumber(s, w, r, repo.ID, number, db.GetIssueByNumber)
+	if !ok {
+		return
+	}
+	if issue.Pending && !isOwner(user, repo) {
+		http.NotFound(w, r)
+		return
+	}
+	s.createComment(w, r, repo, user, issue.ID, base, commentFlow{
+		page: "issue.html", pendingFrag: "comment_pending", commentFrag: "comment",
+		create: func(in commentInput) (commentView, error) {
+			created, err := db.CreateComment(s.database, in.parentID, in.body, in.authorID, in.authorName, in.authorEmail, in.pending)
+			if err != nil {
+				return commentView{}, err
+			}
+			return commentViews([]db.IssueComment{created}, repo.OwnerID, true, base, issueCommentRow)[0], nil
+		},
+	})
+}
+
+// commentFlow names the issue/pull-specific template pieces and the comment
+// constructor used by the shared createComment pipeline.
+type commentFlow struct {
+	page        string
+	pendingFrag string
+	commentFrag string
+	create      func(commentInput) (commentView, error)
+}
+
+// commentInput is everything createComment has resolved by the time it is
+// ready to store the comment.
+type commentInput struct {
+	parentID    int64
+	body        string
+	authorID    int64
+	authorName  string
+	authorEmail string
+	pending     bool
+}
+
+// createComment is the shared guest/owner comment pipeline for issues and
+// pull requests.
+func (s *Server) createComment(w http.ResponseWriter, r *http.Request, repo db.Repo, user *db.User, parentID int64, base string, f commentFlow) {
+	trusted := isOwner(user, repo)
+	if !canWriteContent(user, repo) {
+		http.NotFound(w, r)
+		return
+	}
+	if !trusted && !s.guestComments.allow(clientIP(r)) {
+		http.Error(w, "too many comments; try again later", http.StatusTooManyRequests)
+		return
+	}
+
+	r.Body = http.MaxBytesReader(w, r.Body, issueFormMax)
+	if err := r.ParseForm(); err != nil {
+		http.Error(w, "bad form", http.StatusBadRequest)
+		return
+	}
+	body := formText(r, "body", maxIssueBody)
+	if body == "" {
+		if isHTMX(r) {
+			http.Error(w, "a comment cannot be empty", http.StatusUnprocessableEntity)
+		} else {
+			http.Redirect(w, r, base, http.StatusSeeOther)
+		}
+		return
+	}
+	if !trusted && strings.TrimSpace(r.FormValue(honeypotField)) != "" {
+		if isHTMX(r) {
+			s.renderFragment(w, f.page, f.pendingFrag, nil)
+		} else {
+			http.Redirect(w, r, base+"?"+submittedParam+"=1", http.StatusSeeOther)
+		}
+		return
+	}
+
+	authorID, authorName, authorEmail := issueIdentity(r, user, r.PathValue("user"))
+	view, err := f.create(commentInput{parentID, body, authorID, authorName, authorEmail, !trusted})
+	if err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	if !trusted {
+		if isHTMX(r) {
+			s.renderFragment(w, f.page, f.pendingFrag, nil)
+		} else {
+			http.Redirect(w, r, base+"?"+submittedParam+"=1", http.StatusSeeOther)
+		}
+		return
+	}
+	if isHTMX(r) {
+		s.renderFragment(w, f.page, f.commentFrag, view)
+		return
+	}
+	http.Redirect(w, r, base, http.StatusSeeOther)
+}
+
+// handleApproveIssue publishes a pending issue (owner-only).
+func (s *Server) handleApproveIssue(w http.ResponseWriter, r *http.Request) {
+	s.moderateNumber(w, r, func(repo db.Repo, number int64) error {
+		return db.ApproveIssue(s.database, repo.ID, number)
+	}, issueBasePath(r)+"/"+r.PathValue("number"))
+}
+
+// handleDeleteIssue removes an issue (owner-only).
+func (s *Server) handleDeleteIssue(w http.ResponseWriter, r *http.Request) {
+	s.moderateNumber(w, r, func(repo db.Repo, number int64) error {
+		return db.DeleteIssue(s.database, repo.ID, number)
+	}, issueBasePath(r))
+}
+
+// moderateNumber resolves the owner-only thread target, runs fn on it, and
+// redirects to the caller's next page. Shared by issue and PR moderation.
+func (s *Server) moderateNumber(w http.ResponseWriter, r *http.Request, fn func(db.Repo, int64) error, redirect string) {
+	repo, _, number, ok := s.ownerNumber(w, r)
+	if !ok {
+		return
+	}
+	if err := fn(repo, number); err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	http.Redirect(w, r, redirect, http.StatusSeeOther)
+}
+
+// repoNumber resolves a repo page and parses the {number} path value.
+func (s *Server) repoNumber(w http.ResponseWriter, r *http.Request) (db.Repo, *db.User, int64, bool) {
+	repo, user, ok := s.repoPage(w, r)
+	if !ok {
+		return db.Repo{}, nil, 0, false
+	}
+	number, ok := issueNumber(r)
+	if !ok {
+		http.NotFound(w, r)
+		return db.Repo{}, nil, 0, false
+	}
+	return repo, user, number, true
+}
+
+// ownerNumber resolves a repo page, requires ownership, and parses {number}.
+func (s *Server) ownerNumber(w http.ResponseWriter, r *http.Request) (db.Repo, *db.User, int64, bool) {
+	repo, user, number, ok := s.repoNumber(w, r)
+	if !ok {
+		return db.Repo{}, nil, 0, false
+	}
+	if !isOwner(user, repo) {
+		http.NotFound(w, r)
+		return db.Repo{}, nil, 0, false
+	}
+	return repo, user, number, true
+}
+
+// handleModerateComment approves or deletes an issue comment (owner-only).
+func (s *Server) handleModerateComment(w http.ResponseWriter, r *http.Request, approve bool) {
+	repo, _, number, ok := s.ownerNumber(w, r)
+	if !ok {
+		return
+	}
+	base := threadHref(issueBasePath(r), number)
+	issue, ok := fetchByNumber(s, w, r, repo.ID, number, db.GetIssueByNumber)
+	if !ok {
+		return
+	}
+	id, ok := pathID(r)
+	if !ok {
+		http.NotFound(w, r)
+		return
+	}
+	moderate := db.ApproveComment
+	if !approve {
+		moderate = db.DeleteComment
+	}
+	if err := moderate(s.database, issue.ID, id); err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	http.Redirect(w, r, base, http.StatusSeeOther)
+}
diff --git a/internal/web/issues_test.go b/internal/web/issues_test.go
new file mode 100644
index 0000000..fb33eee
--- /dev/null
+++ b/internal/web/issues_test.go
@@ -0,0 +1,507 @@
+package web
+
+import (
+	"errors"
+	"net/http"
+	"net/url"
+	"strconv"
+	"strings"
+	"testing"
+
+	"git.josie-c.com/josie/simplegit/internal/db"
+)
+
+func postIssue(t *testing.T, client *http.Client, server string, owner, repo string, form url.Values) *http.Response {
+	t.Helper()
+	resp, err := client.PostForm(server+"/"+owner+"/"+repo+"/issues/new", form)
+	if err != nil {
+		t.Fatalf("POST issue: %v", err)
+	}
+	return resp
+}
+
+// noFollow stops the client at the redirect so tests can assert on 303s.
+func noFollow(c *http.Client) *http.Client {
+	c.CheckRedirect = func(*http.Request, []*http.Request) error {
+		return http.ErrUseLastResponse
+	}
+	return c
+}
+
+// The route table must register without a ServeMux conflict; New().Handler()
+// panics if two issue patterns are mutually non-specific.
+func TestIssueRoutesRegister(t *testing.T) {
+	httpServer, _, _ := newTestServer(t)
+	client := noFollow(newLoggedInClient(t, httpServer))
+	createRepo(t, client, httpServer, "pub", "public")
+
+	for _, path := range []string{
+		"/josie/pub/issues",
+		"/josie/pub/issues/new",
+	} {
+		resp, err := client.Get(httpServer.URL + path)
+		if err != nil {
+			t.Fatalf("GET %s: %v", path, err)
+		}
+		readAll(t, resp)
+		if resp.StatusCode != http.StatusOK {
+			t.Errorf("GET %s status = %d, want 200", path, resp.StatusCode)
+		}
+	}
+}
+
+func TestOwnerFilesPublishedIssue(t *testing.T) {
+	httpServer, database, _ := newTestServer(t)
+	client := noFollow(newLoggedInClient(t, httpServer))
+	createRepo(t, client, httpServer, "pub", "public")
+
+	resp := postIssue(t, client, httpServer.URL, "josie", "pub", url.Values{
+		"title": {"hello"}, "body": {"**bold**"},
+	})
+	if resp.StatusCode != http.StatusSeeOther {
+		t.Fatalf("owner POST issue status = %d, want 303", resp.StatusCode)
+	}
+
+	repo, err := db.GetRepoByName(database, "josie", "pub")
+	if err != nil {
+		t.Fatalf("GetRepoByName: %v", err)
+	}
+	issues, err := db.ListIssues(database, repo.ID, false, "")
+	if err != nil {
+		t.Fatalf("ListIssues: %v", err)
+	}
+	if len(issues) != 1 || issues[0].Pending {
+		t.Fatalf("issues = %+v, want one published issue", issues)
+	}
+
+	anonymous := &http.Client{}
+	view, err := anonymous.Get(httpServer.URL + "/josie/pub/issues/1")
+	if err != nil {
+		t.Fatalf("anonymous GET issue: %v", err)
+	}
+	body := readAll(t, view)
+	if view.StatusCode != http.StatusOK {
+		t.Fatalf("anonymous issue status = %d, want 200", view.StatusCode)
+	}
+	if !strings.Contains(body, "opened by josie") || !strings.Contains(body, "owner") {
+		t.Errorf("issue page lacks owner attribution: %q", body)
+	}
+	if !strings.Contains(body, "<strong>bold</strong>") {
+		t.Errorf("issue body not rendered as markdown: %q", body)
+	}
+}
+
+// A guest filing the identical issue twice gets the success page twice but
+// only one row is stored — no oracle for probing, no moderation pile-up.
+func TestGuestIssueDuplicateSilentlyDeduped(t *testing.T) {
+	httpServer, database, _ := newTestServer(t)
+	owner := noFollow(newLoggedInClient(t, httpServer))
+	createRepo(t, owner, httpServer, "pub", "public")
+
+	guest := noFollow(&http.Client{})
+	form := url.Values{
+		"title": {"typo in readme"}, "body": {"please fix"}, "author_name": {"passer-by"},
+	}
+	first := postIssue(t, guest, httpServer.URL, "josie", "pub", form)
+	body := readAll(t, first)
+	if first.StatusCode != http.StatusOK || !strings.Contains(body, "awaiting review") {
+		t.Fatalf("first submit status=%d body=%q", first.StatusCode, body)
+	}
+	second := postIssue(t, guest, httpServer.URL, "josie", "pub", form)
+	body = readAll(t, second)
+	if second.StatusCode != http.StatusOK || !strings.Contains(body, "awaiting review") {
+		t.Fatalf("duplicate submit status=%d body=%q, want the same notice", second.StatusCode, body)
+	}
+
+	repo, _ := db.GetRepoByName(database, "josie", "pub")
+	issues, _ := db.ListIssues(database, repo.ID, true, "")
+	if len(issues) != 1 {
+		t.Errorf("issues = %d rows, want 1 after dedupe", len(issues))
+	}
+}
+
+// A guest claiming the repo owner's display name is stored as Anonymous, so
+// an approved row can never read as the owner's.
+func TestGuestCannotClaimOwnerName(t *testing.T) {
+	httpServer, database, _ := newTestServer(t)
+	owner := noFollow(newLoggedInClient(t, httpServer))
+	createRepo(t, owner, httpServer, "pub", "public")
+
+	guest := noFollow(&http.Client{})
+	resp := postIssue(t, guest, httpServer.URL, "josie", "pub", url.Values{
+		"title": {"hello"}, "body": {"world"}, "author_name": {"JoSie"},
+	})
+	body := readAll(t, resp)
+	if resp.StatusCode != http.StatusOK || !strings.Contains(body, "awaiting review") {
+		t.Fatalf("guest submit status=%d body=%q", resp.StatusCode, body)
+	}
+
+	repo, _ := db.GetRepoByName(database, "josie", "pub")
+	issues, _ := db.ListIssues(database, repo.ID, true, "")
+	if len(issues) != 1 {
+		t.Fatalf("issues = %d rows, want 1", len(issues))
+	}
+	if issues[0].AuthorName != "Anonymous" {
+		t.Errorf("AuthorName = %q, want Anonymous for a guest claiming the owner name", issues[0].AuthorName)
+	}
+}
+
+// The shared guest_fields define must render on the anonymous new-issue
+// form and on an issue page's comment form.
+func TestGuestFieldsRender(t *testing.T) {
+	httpServer, _, _ := newTestServer(t)
+	owner := noFollow(newLoggedInClient(t, httpServer))
+	createRepo(t, owner, httpServer, "pub", "public")
+
+	resp, err := httpServer.Client().Get(httpServer.URL + "/josie/pub/issues/new")
+	if err != nil {
+		t.Fatalf("GET issues/new: %v", err)
+	}
+	body := readAll(t, resp)
+	if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="author_name"`) || !strings.Contains(body, `name="author_email"`) {
+		t.Fatalf("anonymous new-issue form lacks the guest fieldset: status=%d body=%q", resp.StatusCode, body)
+	}
+
+	filed := postIssue(t, owner, httpServer.URL, "josie", "pub", url.Values{
+		"title": {"owner issue"}, "body": {"body"},
+	})
+	readAll(t, filed)
+
+	resp, err = httpServer.Client().Get(httpServer.URL + "/josie/pub/issues/1")
+	if err != nil {
+		t.Fatalf("GET issue view: %v", err)
+	}
+	body = readAll(t, resp)
+	if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="comment_name"`) {
+		t.Fatalf("anonymous issue view lacks the guest comment fieldset: status=%d body=%q", resp.StatusCode, body)
+	}
+}
+
+func TestGuestIssuePendingModeration(t *testing.T) {
+	httpServer, database, _ := newTestServer(t)
+	owner := noFollow(newLoggedInClient(t, httpServer))
+	createRepo(t, owner, httpServer, "pub", "public")
+
+	guest := noFollow(&http.Client{})
+	resp := postIssue(t, guest, httpServer.URL, "josie", "pub", url.Values{
+		"title": {"typo in readme"}, "body": {"please fix"}, "author_name": {"passer-by"},
+	})
+	body := readAll(t, resp)
+	if resp.StatusCode != http.StatusOK || !strings.Contains(body, "awaiting review") {
+		t.Fatalf("guest submit status=%d body=%q, want a review notice", resp.StatusCode, body)
+	}
+
+	repo, _ := db.GetRepoByName(database, "josie", "pub")
+	issues, _ := db.ListIssues(database, repo.ID, true, "")
+	if len(issues) != 1 || !issues[0].Pending || issues[0].AuthorID.Valid {
+		t.Fatalf("issues = %+v, want one pending guest issue with NULL author_id", issues)
+	}
+	if issues[0].AuthorName != "passer-by" {
+		t.Errorf("AuthorName = %q, want passer-by", issues[0].AuthorName)
+	}
+
+	// Hidden from the public both in the list and by direct URL.
+	list, err := guest.Get(httpServer.URL + "/josie/pub/issues")
+	if err != nil {
+		t.Fatalf("anonymous list: %v", err)
+	}
+	if body := readAll(t, list); strings.Contains(body, "typo in readme") {
+		t.Error("pending issue leaked into the anonymous list")
+	}
+	direct, err := guest.Get(httpServer.URL + "/josie/pub/issues/1")
+	if err != nil {
+		t.Fatalf("anonymous direct: %v", err)
+	}
+	readAll(t, direct)
+	if direct.StatusCode != http.StatusNotFound {
+		t.Errorf("anonymous pending issue status = %d, want 404", direct.StatusCode)
+	}
+
+	// The owner sees it and can approve it.
+	ownerList, err := owner.Get(httpServer.URL + "/josie/pub/issues")
+	if err != nil {
+		t.Fatalf("owner list: %v", err)
+	}
+	if body := readAll(t, ownerList); !strings.Contains(body, "typo in readme") || !strings.Contains(body, "awaiting review") {
+		t.Errorf("owner list lacks the pending issue: %q", body)
+	}
+	approve, err := owner.Post(httpServer.URL+"/josie/pub/issues/1/approve", "", nil)
+	if err != nil {
+		t.Fatalf("approve: %v", err)
+	}
+	readAll(t, approve)
+	if approve.StatusCode != http.StatusSeeOther {
+		t.Fatalf("approve status = %d, want 303", approve.StatusCode)
+	}
+	published, err := guest.Get(httpServer.URL + "/josie/pub/issues/1")
+	if err != nil {
+		t.Fatalf("anonymous after approve: %v", err)
+	}
+	if body := readAll(t, published); published.StatusCode != http.StatusOK || !strings.Contains(body, "typo in readme") {
+		t.Errorf("approved issue not public: status=%d body=%q", published.StatusCode, body)
+	}
+}
+
+func TestPrivateRepoIssuesOwnerOnly(t *testing.T) {
+	httpServer, database, _ := newTestServer(t)
+	owner := noFollow(newLoggedInClient(t, httpServer))
+	createRepo(t, owner, httpServer, "priv", "private")
+	addUser(t, database, "mallory", "pw")
+
+	// Anonymous on a private repo gets the sign-in redirect.
+	anon := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
+		return http.ErrUseLastResponse
+	}}
+	resp, err := anon.Get(httpServer.URL + "/josie/priv/issues")
+	if err != nil {
+		t.Fatalf("anonymous private issues: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusNotFound {
+		t.Errorf("anonymous private issues = %d, want 404 (no existence oracle)", resp.StatusCode)
+	}
+
+	// A signed-in non-owner sees 404 and cannot file.
+	mallory := loginAs(t, httpServer, "mallory", "pw")
+	resp, err = mallory.Get(httpServer.URL + "/josie/priv/issues")
+	if err != nil {
+		t.Fatalf("mallory private issues: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusNotFound {
+		t.Errorf("mallory private issues status = %d, want 404", resp.StatusCode)
+	}
+	resp = postIssue(t, mallory, httpServer.URL, "josie", "priv", url.Values{"title": {"x"}})
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusNotFound {
+		t.Errorf("mallory file on private status = %d, want 404", resp.StatusCode)
+	}
+}
+
+func TestCloseReopenOwnerOnly(t *testing.T) {
+	httpServer, database, _ := newTestServer(t)
+	owner := noFollow(newLoggedInClient(t, httpServer))
+	createRepo(t, owner, httpServer, "pub", "public")
+	addUser(t, database, "mallory", "pw")
+	if resp := postIssue(t, owner, httpServer.URL, "josie", "pub", url.Values{"title": {"t"}}); resp.StatusCode != http.StatusSeeOther {
+		t.Fatalf("owner issue status = %d", resp.StatusCode)
+	}
+
+	mallory := loginAs(t, httpServer, "mallory", "pw")
+	resp, err := mallory.Post(httpServer.URL+"/josie/pub/issues/1/close", "", nil)
+	if err != nil {
+		t.Fatalf("mallory close: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusNotFound {
+		t.Errorf("non-owner close status = %d, want 404", resp.StatusCode)
+	}
+
+	resp, err = owner.Post(httpServer.URL+"/josie/pub/issues/1/close", "", nil)
+	if err != nil {
+		t.Fatalf("owner close: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusSeeOther {
+		t.Fatalf("owner close status = %d, want 303", resp.StatusCode)
+	}
+	repo, _ := db.GetRepoByName(database, "josie", "pub")
+	issue, _ := db.GetIssueByNumber(database, repo.ID, 1)
+	if issue.State != "closed" || !issue.ClosedAt.Valid {
+		t.Errorf("issue after close = %+v, want closed with closed_at", issue)
+	}
+}
+
+func TestGuestCommentModeration(t *testing.T) {
+	httpServer, database, _ := newTestServer(t)
+	owner := noFollow(newLoggedInClient(t, httpServer))
+	createRepo(t, owner, httpServer, "pub", "public")
+	postIssue(t, owner, httpServer.URL, "josie", "pub", url.Values{"title": {"topic"}})
+
+	guest := noFollow(&http.Client{})
+	resp, err := guest.PostForm(httpServer.URL+"/josie/pub/issues/1/comments",
+		url.Values{"body": {"guest says hi"}, "author_name": {"anon"}})
+	if err != nil {
+		t.Fatalf("guest comment: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusSeeOther {
+		t.Fatalf("guest comment status = %d, want 303", resp.StatusCode)
+	}
+
+	repo, _ := db.GetRepoByName(database, "josie", "pub")
+	issue, _ := db.GetIssueByNumber(database, repo.ID, 1)
+
+	// Guest comment is invisible to the public.
+	resp, err = guest.Get(httpServer.URL + "/josie/pub/issues/1")
+	if err != nil {
+		t.Fatalf("guest view: %v", err)
+	}
+	if body := readAll(t, resp); strings.Contains(body, "guest says hi") {
+		t.Error("pending guest comment visible publicly")
+	}
+	comments, _ := db.ListComments(database, issue.ID, false)
+	if len(comments) != 0 {
+		t.Errorf("public comments = %d, want 0", len(comments))
+	}
+
+	// Owner sees it, approves it, and it becomes public.
+	resp, err = owner.Get(httpServer.URL + "/josie/pub/issues/1")
+	if err != nil {
+		t.Fatalf("owner view: %v", err)
+	}
+	body := readAll(t, resp)
+	if !strings.Contains(body, "guest says hi") || !strings.Contains(body, "pending") {
+		t.Errorf("owner view lacks pending comment: %q", body)
+	}
+	comments, _ = db.ListComments(database, issue.ID, true)
+	if len(comments) != 1 {
+		t.Fatalf("owner comments = %d, want 1", len(comments))
+	}
+	resp, err = owner.Post(httpServer.URL+"/josie/pub/issues/1/comments/"+strconv.FormatInt(comments[0].ID, 10)+"/approve", "", nil)
+	if err != nil {
+		t.Fatalf("approve comment: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusSeeOther {
+		t.Fatalf("approve comment status = %d, want 303", resp.StatusCode)
+	}
+	resp, _ = guest.Get(httpServer.URL + "/josie/pub/issues/1")
+	if body := readAll(t, resp); !strings.Contains(body, "guest says hi") {
+		t.Error("approved comment still hidden")
+	}
+}
+
+func TestOwnerCommentHTMXFragment(t *testing.T) {
+	httpServer, _, _ := newTestServer(t)
+	owner := noFollow(newLoggedInClient(t, httpServer))
+	createRepo(t, owner, httpServer, "pub", "public")
+	postIssue(t, owner, httpServer.URL, "josie", "pub", url.Values{"title": {"topic"}})
+
+	req, err := http.NewRequest("POST", httpServer.URL+"/josie/pub/issues/1/comments",
+		strings.NewReader(url.Values{"body": {"a reply"}}.Encode()))
+	if err != nil {
+		t.Fatalf("NewRequest: %v", err)
+	}
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	req.Header.Set("HX-Request", "true")
+	resp, err := owner.Do(req)
+	if err != nil {
+		t.Fatalf("htmx comment: %v", err)
+	}
+	body := readAll(t, resp)
+	if resp.StatusCode != http.StatusOK {
+		t.Fatalf("htmx comment status = %d, want 200", resp.StatusCode)
+	}
+	if !strings.Contains(body, `id="comment-`) {
+		t.Errorf("fragment lacks a comment article: %q", body)
+	}
+	if strings.Contains(body, "<html") {
+		t.Error("htmx response is a full page, want a fragment")
+	}
+}
+
+func TestIssueBodyEscapesHTML(t *testing.T) {
+	httpServer, _, _ := newTestServer(t)
+	owner := noFollow(newLoggedInClient(t, httpServer))
+	createRepo(t, owner, httpServer, "pub", "public")
+	postIssue(t, owner, httpServer.URL, "josie", "pub", url.Values{
+		"title": {"xss"}, "body": {"<script>alert(1)</script>"},
+	})
+
+	resp, err := (&http.Client{}).Get(httpServer.URL + "/josie/pub/issues/1")
+	if err != nil {
+		t.Fatalf("GET issue: %v", err)
+	}
+	body := readAll(t, resp)
+	if strings.Contains(body, "<script>alert(1)</script>") {
+		t.Error("raw script survived markdown rendering")
+	}
+	if !strings.Contains(body, "&lt;script&gt;") {
+		t.Errorf("expected escaped script text: %q", body)
+	}
+}
+
+func TestGuestHoneypotDropsIssue(t *testing.T) {
+	httpServer, database, _ := newTestServer(t)
+	owner := noFollow(newLoggedInClient(t, httpServer))
+	createRepo(t, owner, httpServer, "pub", "public")
+
+	guest := noFollow(&http.Client{})
+	resp := postIssue(t, guest, httpServer.URL, "josie", "pub", url.Values{
+		"title": {"spam"}, "website": {"http://spam.example"},
+	})
+	readAll(t, resp)
+
+	repo, _ := db.GetRepoByName(database, "josie", "pub")
+	issues, _ := db.ListIssues(database, repo.ID, true, "")
+	if len(issues) != 0 {
+		t.Errorf("honeypot issue was stored: %+v", issues)
+	}
+}
+
+func TestIssueNumberNotFound(t *testing.T) {
+	httpServer, _, _ := newTestServer(t)
+	owner := noFollow(newLoggedInClient(t, httpServer))
+	createRepo(t, owner, httpServer, "pub", "public")
+
+	resp, err := owner.Get(httpServer.URL + "/josie/pub/issues/99")
+	if err != nil {
+		t.Fatalf("GET missing issue: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusNotFound {
+		t.Errorf("missing issue status = %d, want 404", resp.StatusCode)
+	}
+	resp, err = owner.Get(httpServer.URL + "/josie/pub/issues/notanumber")
+	if err != nil {
+		t.Fatalf("GET bad issue number: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusNotFound {
+		t.Errorf("bad issue number status = %d, want 404", resp.StatusCode)
+	}
+}
+
+func TestIssueDeletedOwnerOnly(t *testing.T) {
+	httpServer, database, _ := newTestServer(t)
+	owner := noFollow(newLoggedInClient(t, httpServer))
+	createRepo(t, owner, httpServer, "pub", "public")
+	addUser(t, database, "mallory", "pw")
+	postIssue(t, owner, httpServer.URL, "josie", "pub", url.Values{"title": {"doomed"}})
+
+	mallory := loginAs(t, httpServer, "mallory", "pw")
+	resp, err := mallory.Post(httpServer.URL+"/josie/pub/issues/1/delete", "", nil)
+	if err != nil {
+		t.Fatalf("mallory delete: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusNotFound {
+		t.Errorf("non-owner delete status = %d, want 404", resp.StatusCode)
+	}
+
+	resp, err = owner.Post(httpServer.URL+"/josie/pub/issues/1/delete", "", nil)
+	if err != nil {
+		t.Fatalf("owner delete: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusSeeOther {
+		t.Fatalf("owner delete status = %d, want 303", resp.StatusCode)
+	}
+	repo, _ := db.GetRepoByName(database, "josie", "pub")
+	if _, err := db.GetIssueByNumber(database, repo.ID, 1); !errors.Is(err, db.ErrNotFound) {
+		t.Errorf("issue after delete err = %v, want ErrNotFound", err)
+	}
+}
+
+func TestTruncateKeepsValidUTF8(t *testing.T) {
+	if got := truncate("é", 1); got != "" {
+		t.Errorf("truncate cut mid-rune: got %q, want empty", got)
+	}
+	if got := truncate("aé", 2); got != "a" {
+		t.Errorf("truncate = %q, want %q", got, "a")
+	}
+	if got := truncate("abc", 3); got != "abc" {
+		t.Errorf("truncate = %q, want %q", got, "abc")
+	}
+}
diff --git a/internal/web/profile.go b/internal/web/profile.go
new file mode 100644
index 0000000..26531b8
--- /dev/null
+++ b/internal/web/profile.go
@@ -0,0 +1,47 @@
+package web
+
+import (
+	"errors"
+	"net/http"
+
+	"git.josie-c.com/josie/simplegit/internal/db"
+)
+
+// profileData is the model for a user's profile page: their repos plus the
+// viewer's identity, so the owner sees account actions.
+type profileData struct {
+	Username string
+	Profile  string
+	IsSelf   bool
+	Repos    []repoItem
+}
+
+func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
+	name := r.PathValue("user")
+	profileUser, err := db.GetUserByName(s.database, name)
+	if errors.Is(err, db.ErrNotFound) {
+		http.NotFound(w, r)
+		return
+	}
+	if err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+
+	viewer := currentUser(r)
+	data := profileData{Profile: profileUser.Username}
+	var viewerID int64
+	if viewer != nil {
+		data.Username = viewer.Username
+		viewerID = viewer.ID
+		data.IsSelf = viewer.ID == profileUser.ID
+	}
+
+	repos, err := db.ListRepos(s.database, viewerID)
+	if err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	data.Repos = repoItems(repos, name)
+	s.render(w, "profile.html", http.StatusOK, data)
+}
diff --git a/internal/web/profile_test.go b/internal/web/profile_test.go
new file mode 100644
index 0000000..0415104
--- /dev/null
+++ b/internal/web/profile_test.go
@@ -0,0 +1,65 @@
+package web
+
+import (
+	"net/http"
+	"strings"
+	"testing"
+)
+
+func TestProfileAnonymousListsPublicOnly(t *testing.T) {
+	httpServer, _, _ := newTestServer(t)
+	loggedIn := newLoggedInClient(t, httpServer)
+	createRepo(t, loggedIn, httpServer, "pub", "public")
+	createRepo(t, loggedIn, httpServer, "sec", "private")
+
+	resp, err := (&http.Client{}).Get(httpServer.URL + "/josie")
+	if err != nil {
+		t.Fatalf("GET /josie: %v", err)
+	}
+	body := readAll(t, resp)
+	if resp.StatusCode != http.StatusOK {
+		t.Fatalf("status = %d, want 200: %q", resp.StatusCode, body)
+	}
+	if !strings.Contains(body, "/josie/pub") {
+		t.Error("public repo missing from profile")
+	}
+	if strings.Contains(body, "/josie/sec") {
+		t.Error("private repo leaked to anonymous profile")
+	}
+	if strings.Contains(body, "sign out") {
+		t.Error("anonymous profile shows account actions")
+	}
+}
+
+func TestProfileOwnerShowsAccountActions(t *testing.T) {
+	httpServer, _, _ := newTestServer(t)
+	loggedIn := newLoggedInClient(t, httpServer)
+	createRepo(t, loggedIn, httpServer, "sec", "private")
+
+	resp, err := loggedIn.Get(httpServer.URL + "/josie")
+	if err != nil {
+		t.Fatalf("GET /josie: %v", err)
+	}
+	body := readAll(t, resp)
+	if resp.StatusCode != http.StatusOK {
+		t.Fatalf("status = %d, want 200: %q", resp.StatusCode, body)
+	}
+	if !strings.Contains(body, "/josie/sec") {
+		t.Error("owner profile missing private repo")
+	}
+	if !strings.Contains(body, "sign out") || !strings.Contains(body, `href="/settings"`) {
+		t.Errorf("owner profile lacks sign out/settings: %q", body)
+	}
+}
+
+func TestProfileUnknownUser(t *testing.T) {
+	httpServer, _, _ := newTestServer(t)
+	resp, err := (&http.Client{}).Get(httpServer.URL + "/nobody")
+	if err != nil {
+		t.Fatalf("GET /nobody: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusNotFound {
+		t.Errorf("status = %d, want 404", resp.StatusCode)
+	}
+}
diff --git a/internal/web/pulls.go b/internal/web/pulls.go
new file mode 100644
index 0000000..11af6f4
--- /dev/null
+++ b/internal/web/pulls.go
@@ -0,0 +1,460 @@
+package web
+
+import (
+	"errors"
+	"fmt"
+	"html/template"
+	"log"
+	"net/http"
+	"slices"
+	"strings"
+	"time"
+
+	"git.josie-c.com/josie/simplegit/internal/db"
+	"git.josie-c.com/josie/simplegit/internal/git"
+)
+
+func pullBasePath(r *http.Request) string {
+	return "/" + r.PathValue("user") + "/" + r.PathValue("repo") + "/pulls"
+}
+
+type pullListRow struct {
+	Number        int64
+	Title         string
+	State         string
+	Pending       bool
+	Base          string
+	Head          string
+	Author        string
+	AuthorIsOwner bool
+	Created       string
+	Href          string
+}
+
+type pullListData struct {
+	navData
+	Show         string
+	IsOwner      bool
+	CanWrite     bool
+	PendingCount int
+	Pulls        []pullListRow
+}
+
+// handlePulls renders the pull-request list. Anonymous visitors of a public
+// repo see non-pending PRs; the owner additionally sees pending ones.
+func (s *Server) handlePulls(w http.ResponseWriter, r *http.Request) {
+	repo, user, ok := s.repoPage(w, r)
+	if !ok {
+		return
+	}
+	ownerView := isOwner(user, repo)
+	show, state := showFilter(r, stateClosed, stateMerged, showAll)
+
+	pulls, err := db.ListPulls(s.database, repo.ID, ownerView, state)
+	if err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	data := pullListData{
+		navData: nav(r, repo, user, "pulls"), Show: show,
+		IsOwner: ownerView, CanWrite: canWriteContent(user, repo),
+	}
+	if ownerView {
+		if data.PendingCount, err = db.CountPending(s.database, repo.ID); err != nil {
+			s.internalError(w, r, err)
+			return
+		}
+	}
+	for _, p := range pulls {
+		data.Pulls = append(data.Pulls, pullListRow{
+			Number:  p.Number,
+			Title:   p.Title,
+			State:   p.State,
+			Pending: p.Pending,
+			Base:    p.Base,
+			Head:    p.Head,
+			Author:  guestAuthorName(p.AuthorName),
+			AuthorIsOwner: p.AuthorID.Valid &&
+				p.AuthorID.Int64 == repo.OwnerID,
+			Created: issuedAt(p.CreatedAt),
+			Href:    threadHref(pullBasePath(r), p.Number),
+		})
+	}
+	s.render(w, "pulls.html", http.StatusOK, data)
+}
+
+type pullNewData struct {
+	navData
+	IsOwner  bool
+	Branches []string
+	Base     string
+	Head     string
+	Title    string
+	Body     string
+	Error    string
+}
+
+// handlePullNewForm renders the open-PR form, listing the repo's branches.
+func (s *Server) handlePullNewForm(w http.ResponseWriter, r *http.Request) {
+	repo, user, ok := s.repoPage(w, r)
+	if !ok {
+		return
+	}
+	if !canWriteContent(user, repo) {
+		http.NotFound(w, r)
+		return
+	}
+	branches, err := git.Branches(s.repoPathFor(r.PathValue("user"), repo))
+	if err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	base := repo.DefaultBranch
+	head := ""
+	for _, branch := range branches {
+		if branch != base && head == "" {
+			head = branch
+		}
+	}
+	data := pullNewData{
+		navData:  nav(r, repo, user, "pulls"),
+		IsOwner:  isOwner(user, repo),
+		Branches: branches, Base: base, Head: head,
+	}
+	s.render(w, "pull_new.html", http.StatusOK, data)
+}
+
+// handleCreatePull validates the branch pair and stores the PR. The owner's
+// PR is published immediately; a guest's is pending owner moderation.
+func (s *Server) handleCreatePull(w http.ResponseWriter, r *http.Request) {
+	repo, user, ok := s.repoPage(w, r)
+	if !ok {
+		return
+	}
+	repoPath := s.repoPathFor(r.PathValue("user"), repo)
+	trusted := isOwner(user, repo)
+	if !canWriteContent(user, repo) {
+		http.NotFound(w, r)
+		return
+	}
+	if !trusted && !s.guestThreads.allow(clientIP(r)) {
+		http.Error(w, "too many pull requests opened; try again later", http.StatusTooManyRequests)
+		return
+	}
+	branches, err := git.Branches(repoPath)
+	if err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+
+	r.Body = http.MaxBytesReader(w, r.Body, issueFormMax)
+	if err := r.ParseForm(); err != nil {
+		http.Error(w, "bad form", http.StatusBadRequest)
+		return
+	}
+	base := strings.TrimSpace(r.FormValue("base"))
+	head := strings.TrimSpace(r.FormValue("head"))
+	title := formText(r, "title", maxTitleLen)
+	body := formText(r, "body", maxIssueBody)
+	fail := func(status int, msg string) {
+		data := pullNewData{
+			navData: nav(r, repo, user, "pulls"), IsOwner: trusted,
+			Branches: branches, Base: base, Head: head, Title: title, Body: body, Error: msg,
+		}
+		s.render(w, "pull_new.html", status, data)
+	}
+	if title == "" {
+		fail(http.StatusUnprocessableEntity, "a title is required")
+		return
+	}
+	if !validRef(base) || !validRef(head) {
+		fail(http.StatusUnprocessableEntity, "base and head must be branch names")
+		return
+	}
+	if base == head {
+		fail(http.StatusUnprocessableEntity, "base and head must differ")
+		return
+	}
+	if !slices.Contains(branches, base) || !slices.Contains(branches, head) {
+		fail(http.StatusUnprocessableEntity, "both base and head must be existing branches")
+		return
+	}
+	if !trusted && strings.TrimSpace(r.FormValue(honeypotField)) != "" {
+		http.Redirect(w, r, pullBasePath(r)+"?"+submittedParam+"=1", http.StatusSeeOther)
+		return
+	}
+
+	authorID, authorName, authorEmail := issueIdentity(r, user, r.PathValue("user"))
+	if !trusted {
+		dup, err := db.HasDuplicatePull(s.database, repo.ID, title, body, base, head, authorName, authorEmail)
+		if err != nil {
+			s.internalError(w, r, err)
+			return
+		}
+		if dup {
+			// Identical PR already stored; answer exactly like a fresh one.
+			s.render(w, "pull_submitted.html", http.StatusOK, nav(r, repo, user, "pulls"))
+			return
+		}
+	}
+	pull, err := db.CreatePull(s.database, repo.ID, title, body, base, head, authorID, authorName, authorEmail, !trusted)
+	if err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	if trusted {
+		http.Redirect(w, r, threadHref(pullBasePath(r), pull.Number), http.StatusSeeOther)
+		return
+	}
+	s.render(w, "pull_submitted.html", http.StatusOK, nav(r, repo, user, "pulls"))
+}
+
+type pullViewData struct {
+	navData
+	Number        int64
+	Title         string
+	State         string
+	Pending       bool
+	Base          string
+	Head          string
+	Author        string
+	AuthorIsOwner bool
+	AuthorEmail   string
+	Created       string
+	BodyHTML      template.HTML
+	DiffHTML      template.HTML
+	DiffNotice    string
+	MergeCommit   string
+	Comments      []commentView
+	IsOwner       bool
+	CanWrite      bool
+	BasePath      string
+	Submitted     bool
+	Error         string
+}
+
+// handlePullView shows one PR: metadata, the three-dot diff, and comments. A
+// non-owner cannot see a pending PR; pending comments are owner-only.
+func (s *Server) handlePullView(w http.ResponseWriter, r *http.Request) {
+	repo, user, number, ok := s.repoNumber(w, r)
+	if !ok {
+		return
+	}
+	pull, ok := fetchByNumber(s, w, r, repo.ID, number, db.GetPullByNumber)
+	if !ok {
+		return
+	}
+	ownerView := isOwner(user, repo)
+	if pull.Pending && !ownerView {
+		http.NotFound(w, r)
+		return
+	}
+	comments, err := db.ListPullComments(s.database, pull.ID, ownerView)
+	if err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	data := s.newPullViewData(r, repo, user, pull, ownerView)
+	data.Comments = commentViews(comments, repo.OwnerID, ownerView, data.BasePath, pullCommentRow)
+	s.render(w, "pull.html", http.StatusOK, data)
+}
+
+func (s *Server) newPullViewData(r *http.Request, repo db.Repo, user *db.User, pull db.Pull, ownerView bool) pullViewData {
+	data := pullViewData{
+		navData: nav(r, repo, user, "pulls"), Number: pull.Number,
+		Title: pull.Title, State: pull.State, Pending: pull.Pending,
+		Base: pull.Base, Head: pull.Head,
+		Author:        guestAuthorName(pull.AuthorName),
+		AuthorIsOwner: pull.AuthorID.Valid && pull.AuthorID.Int64 == repo.OwnerID,
+		AuthorEmail:   pull.AuthorEmail,
+		Created:       issuedAt(pull.CreatedAt),
+		BodyHTML:      markdownHTML(pull.Body),
+		MergeCommit:   pull.MergeCommit,
+		IsOwner:       ownerView, CanWrite: canWriteContent(user, repo),
+		BasePath:  pullBasePath(r),
+		Submitted: submitted(r),
+	}
+	s.attachPullDiff(&data, repo, pull)
+	return data
+}
+
+// attachPullDiff renders git's three-dot base...head patch. The stored
+// branch names are re-validated before they reach git, so a corrupted or
+// legacy row cannot smuggle options into the diff command.
+func (s *Server) attachPullDiff(data *pullViewData, repo db.Repo, pull db.Pull) {
+	if pull.State == stateMerged {
+		return
+	}
+	if !validRef(pull.Base) || !validRef(pull.Head) {
+		data.DiffNotice = "could not compute the diff between base and head."
+		return
+	}
+	patch, err := git.Diff(s.repoPathFor(data.Owner, repo), pull.Base, pull.Head, maxDiffBytes)
+	if err != nil {
+		data.DiffNotice = "could not compute the diff between base and head."
+		return
+	}
+	if len(patch) == 0 {
+		data.DiffNotice = "No changes between base and head."
+		return
+	}
+	data.DiffHTML, data.DiffNotice = renderDiffHTML(patch)
+}
+
+type pullStateData struct {
+	BasePath string
+	State    string
+	IsOwner  bool
+	Pending  bool
+}
+
+// handlePullMerge merges head into base (owner-only) with a fast-forward when
+// possible, otherwise a merge commit.
+func (s *Server) handlePullMerge(w http.ResponseWriter, r *http.Request) {
+	repo, user, number, ok := s.ownerNumber(w, r)
+	if !ok {
+		return
+	}
+	pull, ok := fetchByNumber(s, w, r, repo.ID, number, db.GetPullByNumber)
+	if !ok {
+		return
+	}
+	if pull.State != stateOpen {
+		s.renderPullError(w, r, repo, user, pull, "This pull request is not open.")
+		return
+	}
+	message := fmt.Sprintf("Merge branch '%s' into %s", pull.Head, pull.Base)
+	sha, _, err := git.Merge(s.repoPathFor(r.PathValue("user"), repo), pull.Base, pull.Head, message, user.Username)
+	switch {
+	case errors.Is(err, git.ErrMergeConflict):
+		s.renderPullError(w, r, repo, user, pull, "This pull request has merge conflicts and cannot be merged automatically.")
+		return
+	case errors.Is(err, git.ErrNoCommonAncestor):
+		s.renderPullError(w, r, repo, user, pull, "Base and head have unrelated histories and cannot be merged.")
+		return
+	case errors.Is(err, git.ErrNotFound):
+		s.renderPullError(w, r, repo, user, pull, "A branch no longer exists; this pull request cannot be merged.")
+		return
+	case errors.Is(err, git.ErrAlreadyMerged):
+		// head's changes are already in base; record the merge without a new commit.
+	case err != nil:
+		s.internalError(w, r, err)
+		return
+	}
+	if err := db.SetPullMerged(s.database, repo.ID, number, sha); err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	_ = db.RecordPush(s.database, r.PathValue("user"), repo.Name, time.Now().Unix(), "")
+	http.Redirect(w, r, threadHref(pullBasePath(r), number), http.StatusSeeOther)
+}
+
+func (s *Server) renderPullError(w http.ResponseWriter, r *http.Request, repo db.Repo, user *db.User, pull db.Pull, msg string) {
+	ownerView := isOwner(user, repo)
+	data := s.newPullViewData(r, repo, user, pull, ownerView)
+	data.Error = msg
+	comments, err := db.ListPullComments(s.database, pull.ID, ownerView)
+	if err != nil {
+		log.Printf("web: list pull comments %d: %v", pull.ID, err)
+	}
+	data.Comments = commentViews(comments, repo.OwnerID, ownerView, data.BasePath, pullCommentRow)
+	s.render(w, "pull.html", http.StatusUnprocessableEntity, data)
+}
+
+// handlePullState closes or reopens a PR (owner-only).
+func (s *Server) handlePullState(w http.ResponseWriter, r *http.Request, state string) {
+	repo, user, number, ok := s.ownerNumber(w, r)
+	if !ok {
+		return
+	}
+	pull, ok := fetchByNumber(s, w, r, repo.ID, number, db.GetPullByNumber)
+	if !ok {
+		return
+	}
+	if pull.State == stateMerged {
+		s.renderPullError(w, r, repo, user, pull, "A merged pull request cannot be reopened.")
+		return
+	}
+	if err := db.SetPullState(s.database, repo.ID, number, state); err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	basePath := threadHref(pullBasePath(r), number)
+	if isHTMX(r) {
+		s.renderFragment(w, "pull.html", "pstate", pullStateData{
+			BasePath: basePath, State: state, IsOwner: true, Pending: pull.Pending,
+		})
+		return
+	}
+	http.Redirect(w, r, basePath, http.StatusSeeOther)
+}
+
+// handleCreatePullComment stores a PR comment through the shared comment
+// pipeline: owner comments publish immediately, guest comments are pending
+// moderation.
+func (s *Server) handleCreatePullComment(w http.ResponseWriter, r *http.Request) {
+	repo, user, number, ok := s.repoNumber(w, r)
+	if !ok {
+		return
+	}
+	basePath := threadHref(pullBasePath(r), number)
+	pull, ok := fetchByNumber(s, w, r, repo.ID, number, db.GetPullByNumber)
+	if !ok {
+		return
+	}
+	if pull.Pending && !isOwner(user, repo) {
+		http.NotFound(w, r)
+		return
+	}
+	s.createComment(w, r, repo, user, pull.ID, basePath, commentFlow{
+		page: "pull.html", pendingFrag: "comment_pending", commentFrag: "comment",
+		create: func(in commentInput) (commentView, error) {
+			created, err := db.CreatePullComment(s.database, in.parentID, in.body, in.authorID, in.authorName, in.authorEmail, in.pending)
+			if err != nil {
+				return commentView{}, err
+			}
+			return commentViews([]db.PullComment{created}, repo.OwnerID, true, basePath, pullCommentRow)[0], nil
+		},
+	})
+}
+
+// handleApprovePull publishes a pending PR (owner-only).
+func (s *Server) handleApprovePull(w http.ResponseWriter, r *http.Request) {
+	s.moderateNumber(w, r, func(repo db.Repo, number int64) error {
+		return db.ApprovePull(s.database, repo.ID, number)
+	}, pullBasePath(r)+"/"+r.PathValue("number"))
+}
+
+// handleDeletePull removes a PR (owner-only).
+func (s *Server) handleDeletePull(w http.ResponseWriter, r *http.Request) {
+	s.moderateNumber(w, r, func(repo db.Repo, number int64) error {
+		return db.DeletePull(s.database, repo.ID, number)
+	}, pullBasePath(r))
+}
+
+// handleModeratePullComment approves or deletes a PR comment (owner-only).
+func (s *Server) handleModeratePullComment(w http.ResponseWriter, r *http.Request, approve bool) {
+	repo, _, number, ok := s.ownerNumber(w, r)
+	if !ok {
+		return
+	}
+	basePath := threadHref(pullBasePath(r), number)
+	pull, ok := fetchByNumber(s, w, r, repo.ID, number, db.GetPullByNumber)
+	if !ok {
+		return
+	}
+	id, ok := pathID(r)
+	if !ok {
+		http.NotFound(w, r)
+		return
+	}
+	moderate := db.ApprovePullComment
+	if !approve {
+		moderate = db.DeletePullComment
+	}
+	if err := moderate(s.database, pull.ID, id); err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	http.Redirect(w, r, basePath, http.StatusSeeOther)
+}
diff --git a/internal/web/pulls_test.go b/internal/web/pulls_test.go
new file mode 100644
index 0000000..e1c556a
--- /dev/null
+++ b/internal/web/pulls_test.go
@@ -0,0 +1,344 @@
+package web
+
+import (
+	"database/sql"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"git.josie-c.com/josie/simplegit/internal/db"
+)
+
+func writeWork(t *testing.T, work, name, content string) {
+	t.Helper()
+	if err := os.WriteFile(filepath.Join(work, name), []byte(content), 0o644); err != nil {
+		t.Fatalf("write %s: %v", name, err)
+	}
+}
+
+// cloneRepo clones ownerAuth'd repo {name} for pushing test branches.
+func cloneRepo(t *testing.T, httpServer *httptest.Server, name string) string {
+	t.Helper()
+	u := mustParse(t, httpServer.URL)
+	repoURL := "http://josie:hunter2@" + u.Host + "/josie/" + name + ".git"
+	work := filepath.Join(t.TempDir(), "work")
+	runGit(t, "", "-c", "credential.helper=", "clone", "-q", repoURL, work)
+	runGit(t, work, "config", "user.email", "t@t")
+	runGit(t, work, "config", "user.name", "t")
+	return work
+}
+
+func TestPullRoutesRegister(t *testing.T) {
+	httpServer, _, _ := newTestServer(t)
+	client := noFollow(newLoggedInClient(t, httpServer))
+	createRepo(t, client, httpServer, "pub", "public")
+	for _, path := range []string{"/josie/pub/pulls", "/josie/pub/pulls/new"} {
+		resp, err := client.Get(httpServer.URL + path)
+		if err != nil {
+			t.Fatalf("GET %s: %v", path, err)
+		}
+		readAll(t, resp)
+		if resp.StatusCode != http.StatusOK {
+			t.Errorf("GET %s = %d, want 200", path, resp.StatusCode)
+		}
+	}
+}
+
+func TestOwnerPullOpenViewMergeFastForward(t *testing.T) {
+	httpServer, database, dataDir := newTestServer(t)
+	owner := noFollow(newLoggedInClient(t, httpServer))
+	createRepo(t, owner, httpServer, "pub", "public")
+	work := cloneRepo(t, httpServer, "pub")
+	writeWork(t, work, "base.txt", "base\n")
+	runGit(t, work, "add", ".")
+	runGit(t, work, "commit", "-qm", "base")
+	runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
+	runGit(t, work, "checkout", "-qb", "feature")
+	writeWork(t, work, "feature.txt", "feature\n")
+	runGit(t, work, "add", ".")
+	runGit(t, work, "commit", "-qm", "feature")
+	runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/feature")
+
+	resp, err := owner.PostForm(httpServer.URL+"/josie/pub/pulls/new",
+		url.Values{"base": {"main"}, "head": {"feature"}, "title": {"Add feature"}, "body": {"the why"}})
+	if err != nil {
+		t.Fatalf("open PR: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusSeeOther {
+		t.Fatalf("open PR status = %d, want 303", resp.StatusCode)
+	}
+
+	anon := noFollow(&http.Client{})
+	view, err := anon.Get(httpServer.URL + "/josie/pub/pulls/1")
+	if err != nil {
+		t.Fatalf("GET PR: %v", err)
+	}
+	body := readAll(t, view)
+	if view.StatusCode != http.StatusOK || !strings.Contains(body, "Add feature") || !strings.Contains(body, "feature.txt") {
+		t.Fatalf("PR view status=%d body=%q", view.StatusCode, body)
+	}
+
+	merge, err := owner.Post(httpServer.URL+"/josie/pub/pulls/1/merge", "", nil)
+	if err != nil {
+		t.Fatalf("merge: %v", err)
+	}
+	readAll(t, merge)
+	if merge.StatusCode != http.StatusSeeOther {
+		t.Fatalf("merge status = %d, want 303", merge.StatusCode)
+	}
+	pull := pullByNumber(t, database, "pub", 1)
+	if pull.State != "merged" || pull.MergeCommit == "" {
+		t.Errorf("pull after merge = %+v, want merged with commit", pull)
+	}
+	bare := filepath.Join(dataDir, "repos", "josie", "pub.git")
+	mainSHA := strings.TrimSpace(runGitOut(t, bare, "rev-parse", "refs/heads/main"))
+	featureSHA := strings.TrimSpace(runGitOut(t, bare, "rev-parse", "refs/heads/feature"))
+	if mainSHA != featureSHA {
+		t.Errorf("main = %s, want fast-forwarded to feature %s", mainSHA, featureSHA)
+	}
+}
+
+func TestGuestPullPendingModeration(t *testing.T) {
+	httpServer, _, _ := newTestServer(t)
+	owner := noFollow(newLoggedInClient(t, httpServer))
+	createRepo(t, owner, httpServer, "pub", "public")
+	work := cloneRepo(t, httpServer, "pub")
+	writeWork(t, work, "base.txt", "base\n")
+	runGit(t, work, "add", ".")
+	runGit(t, work, "commit", "-qm", "base")
+	runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
+	runGit(t, work, "checkout", "-qb", "feature")
+	writeWork(t, work, "feature.txt", "feature\n")
+	runGit(t, work, "add", ".")
+	runGit(t, work, "commit", "-qm", "feature")
+	runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/feature")
+
+	guest := noFollow(&http.Client{})
+	resp, err := guest.PostForm(httpServer.URL+"/josie/pub/pulls/new",
+		url.Values{"base": {"main"}, "head": {"feature"}, "title": {"Guest idea"}, "author_name": {"anon"}})
+	if err != nil {
+		t.Fatalf("guest open PR: %v", err)
+	}
+	body := readAll(t, resp)
+	if resp.StatusCode != http.StatusOK || !strings.Contains(body, "awaiting review") {
+		t.Fatalf("guest PR status=%d body=%q, want review notice", resp.StatusCode, body)
+	}
+
+	list, _ := guest.Get(httpServer.URL + "/josie/pub/pulls")
+	if body := readAll(t, list); strings.Contains(body, "Guest idea") {
+		t.Error("pending PR leaked to anonymous list")
+	}
+	direct, _ := guest.Get(httpServer.URL + "/josie/pub/pulls/1")
+	readAll(t, direct)
+	if direct.StatusCode != http.StatusNotFound {
+		t.Errorf("anonymous pending PR = %d, want 404", direct.StatusCode)
+	}
+
+	resp, err = owner.Post(httpServer.URL+"/josie/pub/pulls/1/approve", "", nil)
+	if err != nil {
+		t.Fatalf("approve: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusSeeOther {
+		t.Fatalf("approve status = %d, want 303", resp.StatusCode)
+	}
+	published, _ := guest.Get(httpServer.URL + "/josie/pub/pulls/1")
+	if body := readAll(t, published); published.StatusCode != http.StatusOK || !strings.Contains(body, "Guest idea") {
+		t.Errorf("approved PR not public: %d %q", published.StatusCode, body)
+	}
+}
+
+func TestPullMergeConflictRefused(t *testing.T) {
+	httpServer, database, dataDir := newTestServer(t)
+	owner := noFollow(newLoggedInClient(t, httpServer))
+	createRepo(t, owner, httpServer, "pub", "public")
+	work := cloneRepo(t, httpServer, "pub")
+	writeWork(t, work, "conflict.txt", "original\n")
+	runGit(t, work, "add", ".")
+	runGit(t, work, "commit", "-qm", "base")
+	runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
+	runGit(t, work, "checkout", "-qb", "feature")
+	writeWork(t, work, "conflict.txt", "feature\n")
+	runGit(t, work, "commit", "-aqm", "feature")
+	runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/feature")
+	runGit(t, work, "checkout", "-q", "main")
+	writeWork(t, work, "conflict.txt", "main\n")
+	runGit(t, work, "commit", "-aqm", "main edit")
+	runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
+
+	resp, err := owner.PostForm(httpServer.URL+"/josie/pub/pulls/new",
+		url.Values{"base": {"main"}, "head": {"feature"}, "title": {"Conflicting"}})
+	if err != nil {
+		t.Fatalf("open PR: %v", err)
+	}
+	readAll(t, resp)
+	bare := filepath.Join(dataDir, "repos", "josie", "pub.git")
+	before := strings.TrimSpace(runGitOut(t, bare, "rev-parse", "refs/heads/main"))
+
+	merge, err := owner.Post(httpServer.URL+"/josie/pub/pulls/1/merge", "", nil)
+	if err != nil {
+		t.Fatalf("merge: %v", err)
+	}
+	body := readAll(t, merge)
+	if merge.StatusCode != http.StatusUnprocessableEntity || !strings.Contains(body, "merge conflict") {
+		t.Fatalf("conflict merge status=%d body=%q", merge.StatusCode, body)
+	}
+	after := strings.TrimSpace(runGitOut(t, bare, "rev-parse", "refs/heads/main"))
+	if after != before {
+		t.Errorf("main moved on conflict: %s -> %s", before, after)
+	}
+	pull := pullByNumber(t, database, "pub", 1)
+	if pull.State != "open" {
+		t.Errorf("pull state after conflict = %q, want open", pull.State)
+	}
+}
+
+// Merging branches with no common ancestor must be refused with a readable
+// 422, not an internal error.
+func TestPullMergeUnrelatedHistoriesRefused(t *testing.T) {
+	httpServer, database, dataDir := newTestServer(t)
+	owner := noFollow(newLoggedInClient(t, httpServer))
+	createRepo(t, owner, httpServer, "pub", "public")
+	work := cloneRepo(t, httpServer, "pub")
+	writeWork(t, work, "a.txt", "a\n")
+	runGit(t, work, "add", ".")
+	runGit(t, work, "commit", "-qm", "a")
+	runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
+	runGit(t, work, "checkout", "-q", "--orphan", "lonely")
+	writeWork(t, work, "b.txt", "b\n")
+	runGit(t, work, "add", ".")
+	runGit(t, work, "commit", "-qm", "b")
+	runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/lonely")
+
+	resp, err := owner.PostForm(httpServer.URL+"/josie/pub/pulls/new",
+		url.Values{"base": {"main"}, "head": {"lonely"}, "title": {"Unrelated"}})
+	if err != nil {
+		t.Fatalf("open PR: %v", err)
+	}
+	readAll(t, resp)
+	bare := filepath.Join(dataDir, "repos", "josie", "pub.git")
+	before := strings.TrimSpace(runGitOut(t, bare, "rev-parse", "refs/heads/main"))
+
+	merge, err := owner.Post(httpServer.URL+"/josie/pub/pulls/1/merge", "", nil)
+	if err != nil {
+		t.Fatalf("merge: %v", err)
+	}
+	body := readAll(t, merge)
+	if merge.StatusCode != http.StatusUnprocessableEntity || !strings.Contains(body, "unrelated histories") {
+		t.Fatalf("unrelated merge status=%d body=%q", merge.StatusCode, body)
+	}
+	after := strings.TrimSpace(runGitOut(t, bare, "rev-parse", "refs/heads/main"))
+	if after != before {
+		t.Errorf("main moved on unrelated merge: %s -> %s", before, after)
+	}
+	pull := pullByNumber(t, database, "pub", 1)
+	if pull.State != "open" {
+		t.Errorf("pull state after refused merge = %q, want open", pull.State)
+	}
+}
+
+func TestPullCloseReopenOwnerOnly(t *testing.T) {
+	httpServer, database, _ := newTestServer(t)
+	owner := noFollow(newLoggedInClient(t, httpServer))
+	createRepo(t, owner, httpServer, "pub", "public")
+	work := cloneRepo(t, httpServer, "pub")
+	writeWork(t, work, "a.txt", "a\n")
+	runGit(t, work, "add", ".")
+	runGit(t, work, "commit", "-qm", "a")
+	runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
+	runGit(t, work, "checkout", "-qb", "feature")
+	writeWork(t, work, "b.txt", "b\n")
+	runGit(t, work, "add", ".")
+	runGit(t, work, "commit", "-qm", "b")
+	runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/feature")
+	owner.PostForm(httpServer.URL+"/josie/pub/pulls/new", url.Values{"base": {"main"}, "head": {"feature"}, "title": {"t"}})
+
+	addUser(t, database, "mallory", "pw")
+	mallory := noFollow(loginAs(t, httpServer, "mallory", "pw"))
+	resp, err := mallory.Post(httpServer.URL+"/josie/pub/pulls/1/close", "", nil)
+	if err != nil {
+		t.Fatalf("mallory close: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusNotFound {
+		t.Errorf("non-owner close = %d, want 404", resp.StatusCode)
+	}
+
+	resp, err = owner.Post(httpServer.URL+"/josie/pub/pulls/1/close", "", nil)
+	if err != nil {
+		t.Fatalf("owner close: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusSeeOther {
+		t.Fatalf("owner close = %d, want 303", resp.StatusCode)
+	}
+}
+
+func TestPullOwnerCommentHTMX(t *testing.T) {
+	httpServer, _, _ := newTestServer(t)
+	owner := noFollow(newLoggedInClient(t, httpServer))
+	createRepo(t, owner, httpServer, "pub", "public")
+	work := cloneRepo(t, httpServer, "pub")
+	writeWork(t, work, "a.txt", "a\n")
+	runGit(t, work, "add", ".")
+	runGit(t, work, "commit", "-qm", "a")
+	runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
+	runGit(t, work, "checkout", "-qb", "feature")
+	writeWork(t, work, "b.txt", "b\n")
+	runGit(t, work, "add", ".")
+	runGit(t, work, "commit", "-qm", "b")
+	runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/feature")
+	owner.PostForm(httpServer.URL+"/josie/pub/pulls/new", url.Values{"base": {"main"}, "head": {"feature"}, "title": {"t"}})
+
+	req, _ := http.NewRequest("POST", httpServer.URL+"/josie/pub/pulls/1/comments",
+		strings.NewReader(url.Values{"body": {"looks good"}}.Encode()))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	req.Header.Set("HX-Request", "true")
+	resp, err := owner.Do(req)
+	if err != nil {
+		t.Fatalf("htmx comment: %v", err)
+	}
+	body := readAll(t, resp)
+	if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="comment-`) || strings.Contains(body, "<html") {
+		t.Errorf("pull htmx fragment status=%d body=%q", resp.StatusCode, body)
+	}
+}
+
+// ---- helpers ----
+
+func mustParse(t *testing.T, raw string) *url.URL {
+	t.Helper()
+	u, err := url.Parse(raw)
+	if err != nil {
+		t.Fatalf("parse URL %s: %v", raw, err)
+	}
+	return u
+}
+
+func runGitOut(t *testing.T, dir string, args ...string) string {
+	t.Helper()
+	out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).CombinedOutput()
+	if err != nil {
+		t.Fatalf("git %v: %v: %s", args, err, out)
+	}
+	return string(out)
+}
+
+func pullByNumber(t *testing.T, database *sql.DB, repoName string, number int64) db.Pull {
+	t.Helper()
+	repo, err := db.GetRepoByName(database, "josie", repoName)
+	if err != nil {
+		t.Fatalf("GetRepoByName: %v", err)
+	}
+	pull, err := db.GetPullByNumber(database, repo.ID, number)
+	if err != nil {
+		t.Fatalf("GetPullByNumber: %v", err)
+	}
+	return pull
+}
diff --git a/internal/web/push_test.go b/internal/web/push_test.go
new file mode 100644
index 0000000..9ecb034
--- /dev/null
+++ b/internal/web/push_test.go
@@ -0,0 +1,26 @@
+package web
+
+import (
+	"crypto/rand"
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+func TestLargePush(t *testing.T) {
+	httpServer, _, _ := newTestServer(t)
+	owner := noFollow(newLoggedInClient(t, httpServer))
+	createRepo(t, owner, httpServer, "big", "public")
+	work := cloneRepo(t, httpServer, "big")
+
+	blob := make([]byte, 16<<20)
+	if _, err := rand.Read(blob); err != nil {
+		t.Fatalf("rand: %v", err)
+	}
+	if err := os.WriteFile(filepath.Join(work, "blob.bin"), blob, 0o644); err != nil {
+		t.Fatalf("write blob: %v", err)
+	}
+	runGit(t, work, "add", ".")
+	runGit(t, work, "commit", "-qm", "big")
+	runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
+}
diff --git a/internal/web/ratelimit.go b/internal/web/ratelimit.go
new file mode 100644
index 0000000..348e82b
--- /dev/null
+++ b/internal/web/ratelimit.go
@@ -0,0 +1,74 @@
+package web
+
+import (
+	"sync"
+	"time"
+)
+
+// ipLimiter is a small in-memory sliding-window rate limiter keyed by client
+// IP. It guards unauthenticated guest writes; a periodic sweep inside allow
+// drops keys whose hits have all aged out.
+type ipLimiter struct {
+	mu     sync.Mutex
+	hits   map[string][]time.Time
+	limit  int
+	window time.Duration
+	calls  int
+}
+
+func newIPLimiter(limit int, window time.Duration) *ipLimiter {
+	return &ipLimiter{hits: make(map[string][]time.Time), limit: limit, window: window}
+}
+
+// allow records a hit for key and reports whether it is within the limit.
+func (l *ipLimiter) allow(key string) bool {
+	now := time.Now()
+	cut := now.Add(-l.window)
+
+	l.mu.Lock()
+	defer l.mu.Unlock()
+
+	l.calls++
+	if l.calls%512 == 0 {
+		l.sweep(cut)
+	}
+
+	recent := l.hits[key][:0]
+	for _, t := range l.hits[key] {
+		if t.After(cut) {
+			recent = append(recent, t)
+		}
+	}
+	if len(recent) >= l.limit {
+		l.hits[key] = recent
+		return false
+	}
+	l.hits[key] = append(recent, now)
+	return true
+}
+
+// sweep drops keys whose recorded hits have all left the window. Called
+// with the mutex held.
+func (l *ipLimiter) sweep(cut time.Time) {
+	for key, times := range l.hits {
+		recent := times[:0]
+		for _, t := range times {
+			if t.After(cut) {
+				recent = append(recent, t)
+			}
+		}
+		if len(recent) == 0 {
+			delete(l.hits, key)
+			continue
+		}
+		l.hits[key] = recent
+	}
+}
+
+// reset forgets key's recorded hits, so a limiter keyed on credentials
+// rewards a success (e.g. a signed-in user clears the failed-login window).
+func (l *ipLimiter) reset(key string) {
+	l.mu.Lock()
+	defer l.mu.Unlock()
+	delete(l.hits, key)
+}
diff --git a/internal/web/ratelimit_test.go b/internal/web/ratelimit_test.go
new file mode 100644
index 0000000..6b5f2bd
--- /dev/null
+++ b/internal/web/ratelimit_test.go
@@ -0,0 +1,31 @@
+package web
+
+import (
+	"testing"
+	"time"
+)
+
+func TestIPLimiterWindow(t *testing.T) {
+	limiter := newIPLimiter(2, time.Hour)
+
+	if !limiter.allow("a") || !limiter.allow("a") {
+		t.Fatal("first two hits for one key should be allowed")
+	}
+	if limiter.allow("a") {
+		t.Error("third hit within the window was allowed, want denied")
+	}
+	if !limiter.allow("b") {
+		t.Error("a different key should have its own budget")
+	}
+}
+
+func TestIPLimiterExpiry(t *testing.T) {
+	limiter := newIPLimiter(1, time.Millisecond)
+	if !limiter.allow("a") {
+		t.Fatal("first hit should be allowed")
+	}
+	time.Sleep(5 * time.Millisecond)
+	if !limiter.allow("a") {
+		t.Error("hit after the window should be allowed")
+	}
+}
diff --git a/internal/web/releases.go b/internal/web/releases.go
new file mode 100644
index 0000000..70bf356
--- /dev/null
+++ b/internal/web/releases.go
@@ -0,0 +1,276 @@
+package web
+
+import (
+	"errors"
+	"fmt"
+	"html/template"
+	"log"
+	"mime"
+	"net/http"
+	"net/url"
+	"os"
+	"path/filepath"
+	"strconv"
+
+	"git.josie-c.com/josie/simplegit/internal/db"
+	"git.josie-c.com/josie/simplegit/internal/git"
+)
+
+func releaseBasePath(r *http.Request) string {
+	return "/" + r.PathValue("user") + "/" + r.PathValue("repo") + "/releases"
+}
+
+func (s *Server) releaseUploadDir(releaseID int64) string {
+	return filepath.Join(s.cfg.DataDir, "uploads", "releases", strconv.FormatInt(releaseID, 10))
+}
+
+type releaseListRow struct {
+	Tag     string
+	Title   string
+	Created string
+	Href    string
+}
+
+type releaseTagRow struct {
+	Name   string
+	Commit string
+}
+
+type releaseListData struct {
+	navData
+	Releases []releaseListRow
+	Tags     []releaseTagRow
+}
+
+// handleReleases lists the repo's releases and any tags without one.
+// Releases are made by pushing tags with git; there is no creation UI.
+func (s *Server) handleReleases(w http.ResponseWriter, r *http.Request) {
+	repo, repoPath, user, ok := s.resolveRepo(w, r)
+	if !ok {
+		return
+	}
+	releases, err := db.ListReleases(s.database, repo.ID)
+	if err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	tags, err := git.Tags(repoPath)
+	if err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	released := make(map[string]bool, len(releases))
+	data := releaseListData{navData: nav(r, repo, user, "releases")}
+	for _, release := range releases {
+		released[release.Tag] = true
+		data.Releases = append(data.Releases, releaseListRow{
+			Tag:     release.Tag,
+			Title:   release.Title,
+			Created: issuedAt(release.CreatedAt),
+			Href:    releaseBasePath(r) + "/" + url.PathEscape(release.Tag),
+		})
+	}
+	for _, tag := range tags {
+		if released[tag.Name] {
+			continue
+		}
+		data.Tags = append(data.Tags, releaseTagRow{
+			Name:   tag.Name,
+			Commit: shortSHA(tag.Commit),
+		})
+	}
+	s.render(w, "releases.html", http.StatusOK, data)
+}
+
+type releaseAssetView struct {
+	ID       int64
+	Name     string
+	Size     string
+	Download string
+	Delete   string
+}
+
+type releaseViewData struct {
+	navData
+	Tag        string
+	Title      string
+	Commit     string
+	Created    string
+	Notes      template.HTML
+	Assets     []releaseAssetView
+	IsOwner    bool
+	DeleteHref string
+}
+
+// handleReleaseView shows one release with its notes and assets.
+func (s *Server) handleReleaseView(w http.ResponseWriter, r *http.Request) {
+	repo, repoPath, user, ok := s.resolveRepo(w, r)
+	if !ok {
+		return
+	}
+	tag := r.PathValue("tag")
+	release, err := db.GetReleaseByTag(s.database, repo.ID, tag)
+	if errors.Is(err, db.ErrNotFound) {
+		http.NotFound(w, r)
+		return
+	}
+	if err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	assets, err := db.ListReleaseAssets(s.database, release.ID)
+	if err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	data := releaseViewData{
+		navData: nav(r, repo, user, "releases"),
+		Tag:     release.Tag, Title: release.Title, Created: issuedAt(release.CreatedAt),
+		Notes: markdownHTML(release.Notes), IsOwner: isOwner(user, repo),
+	}
+	tagPath := releaseBasePath(r) + "/" + url.PathEscape(release.Tag)
+	data.DeleteHref = tagPath + "/delete"
+	if tags, err := git.Tags(repoPath); err == nil {
+		for _, t := range tags {
+			if t.Name == release.Tag {
+				data.Commit = shortSHA(t.Commit)
+				break
+			}
+		}
+	}
+	for _, asset := range assets {
+		data.Assets = append(data.Assets, releaseAssetView{
+			ID:       asset.ID,
+			Name:     asset.Filename,
+			Size:     humanSize(asset.Size),
+			Download: releaseBasePath(r) + "/download/" + strconv.FormatInt(asset.ID, 10) + "/" + url.PathEscape(asset.Filename),
+			Delete:   tagPath + "/assets/" + strconv.FormatInt(asset.ID, 10) + "/delete",
+		})
+	}
+	s.render(w, "release.html", http.StatusOK, data)
+}
+
+// handleReleaseDownload serves one asset. Access follows the repo gate, so
+// private repos require the owner. CSP + octet-stream + attachment headers
+// keep the download origin inert even if a viewer opens the file locally.
+func (s *Server) handleReleaseDownload(w http.ResponseWriter, r *http.Request) {
+	repo, _, _, ok := s.resolveRepo(w, r)
+	if !ok {
+		return
+	}
+	id, ok := pathID(r)
+	if !ok {
+		http.NotFound(w, r)
+		return
+	}
+	asset, err := db.GetReleaseAssetForRepo(s.database, repo.ID, id)
+	if errors.Is(err, db.ErrNotFound) {
+		http.NotFound(w, r)
+		return
+	}
+	if err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	file, err := os.Open(filepath.Join(s.releaseUploadDir(asset.ReleaseID), asset.StoredName))
+	if err != nil {
+		http.NotFound(w, r)
+		return
+	}
+	defer file.Close()
+	info, err := file.Stat()
+	if err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	disposition := mime.FormatMediaType("attachment", map[string]string{"filename": asset.Filename})
+	if disposition == "" {
+		disposition = "attachment"
+	}
+	w.Header().Set("Content-Type", "application/octet-stream")
+	w.Header().Set("X-Content-Type-Options", "nosniff")
+	w.Header().Set("Content-Disposition", disposition)
+	w.Header().Set("Content-Security-Policy", "default-src 'none'")
+	http.ServeContent(w, r, asset.Filename, info.ModTime(), file)
+}
+
+// handleDeleteRelease removes a release and its stored assets (owner-only).
+func (s *Server) handleDeleteRelease(w http.ResponseWriter, r *http.Request) {
+	repo, _, _, ok := s.ownerRepo(w, r)
+	if !ok {
+		return
+	}
+	release, ok := s.releaseByTag(w, r, repo)
+	if !ok {
+		return
+	}
+	if err := db.DeleteRelease(s.database, repo.ID, release.ID); err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	if err := os.RemoveAll(s.releaseUploadDir(release.ID)); err != nil {
+		log.Printf("web: remove release assets: %v", err)
+	}
+	http.Redirect(w, r, releaseBasePath(r), http.StatusSeeOther)
+}
+
+// handleDeleteReleaseAsset removes one asset (owner-only).
+func (s *Server) handleDeleteReleaseAsset(w http.ResponseWriter, r *http.Request) {
+	repo, _, _, ok := s.ownerRepo(w, r)
+	if !ok {
+		return
+	}
+	release, ok := s.releaseByTag(w, r, repo)
+	if !ok {
+		return
+	}
+	id, ok := pathID(r)
+	if !ok {
+		http.NotFound(w, r)
+		return
+	}
+	asset, err := db.GetReleaseAssetForRepo(s.database, repo.ID, id)
+	if errors.Is(err, db.ErrNotFound) || (err == nil && asset.ReleaseID != release.ID) {
+		http.NotFound(w, r)
+		return
+	}
+	if err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	if err := db.DeleteReleaseAsset(s.database, release.ID, id); err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	if err := os.Remove(filepath.Join(s.releaseUploadDir(release.ID), asset.StoredName)); err != nil {
+		log.Printf("web: remove release asset: %v", err)
+	}
+	http.Redirect(w, r, releaseBasePath(r)+"/"+url.PathEscape(release.Tag), http.StatusSeeOther)
+}
+
+// releaseByTag resolves the {tag} path value to a release in repo.
+func (s *Server) releaseByTag(w http.ResponseWriter, r *http.Request, repo db.Repo) (db.Release, bool) {
+	release, err := db.GetReleaseByTag(s.database, repo.ID, r.PathValue("tag"))
+	if errors.Is(err, db.ErrNotFound) {
+		http.NotFound(w, r)
+		return db.Release{}, false
+	}
+	if err != nil {
+		s.internalError(w, r, err)
+		return db.Release{}, false
+	}
+	return release, true
+}
+
+func humanSize(n int64) string {
+	const unit = 1024
+	if n < unit {
+		return fmt.Sprintf("%d B", n)
+	}
+	div, exp := int64(unit), 0
+	for v := n / unit; v >= unit; v /= unit {
+		div *= unit
+		exp++
+	}
+	return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
+}
diff --git a/internal/web/releases_test.go b/internal/web/releases_test.go
new file mode 100644
index 0000000..9e54d77
--- /dev/null
+++ b/internal/web/releases_test.go
@@ -0,0 +1,188 @@
+package web
+
+import (
+	"database/sql"
+	"errors"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"testing"
+
+	"git.josie-c.com/josie/simplegit/internal/db"
+)
+
+// seedTag pushes a lightweight tag named tag pointing at HEAD.
+func seedTag(t *testing.T, work, tag string) {
+	t.Helper()
+	runGit(t, work, "tag", tag)
+	runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", tag)
+}
+
+// seedRelease creates a release row plus one on-disk asset directly (there is
+// no create-release UI; rows normally come from the owner's workflow).
+func seedRelease(t *testing.T, database *sql.DB, dataDir, repoName, tag string) (releaseID, assetID int64) {
+	t.Helper()
+	repo, err := db.GetRepoByName(database, "josie", repoName)
+	if err != nil {
+		t.Fatalf("GetRepoByName: %v", err)
+	}
+	owner, err := db.GetUserByName(database, "josie")
+	if err != nil {
+		t.Fatalf("GetUserByName: %v", err)
+	}
+	release, err := db.CreateRelease(database, repo.ID, tag, "First release", "**bold** notes", owner.ID)
+	if err != nil {
+		t.Fatalf("CreateRelease: %v", err)
+	}
+	dir := filepath.Join(dataDir, "uploads", "releases", strconv.FormatInt(release.ID, 10))
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir assets: %v", err)
+	}
+	if err := os.WriteFile(filepath.Join(dir, "stored"), []byte("hello world"), 0o644); err != nil {
+		t.Fatalf("write asset: %v", err)
+	}
+	asset, err := db.CreateReleaseAsset(database, release.ID, "artifact.bin", "stored", int64(len("hello world")))
+	if err != nil {
+		t.Fatalf("CreateReleaseAsset: %v", err)
+	}
+	return release.ID, asset.ID
+}
+
+func TestReleaseRoutesRegister(t *testing.T) {
+	httpServer, _, _ := newTestServer(t)
+	client := noFollow(newLoggedInClient(t, httpServer))
+	createRepo(t, client, httpServer, "pub", "public")
+	for _, path := range []string{"/josie/pub/releases"} {
+		resp, err := client.Get(httpServer.URL + path)
+		if err != nil {
+			t.Fatalf("GET %s: %v", path, err)
+		}
+		readAll(t, resp)
+		if resp.StatusCode != http.StatusOK {
+			t.Errorf("GET %s = %d, want 200", path, resp.StatusCode)
+		}
+	}
+}
+
+func TestReleaseViewAndDownload(t *testing.T) {
+	httpServer, database, dataDir := newTestServer(t)
+	owner := noFollow(newLoggedInClient(t, httpServer))
+	createRepo(t, owner, httpServer, "pub", "public")
+	work := cloneRepo(t, httpServer, "pub")
+	writeWork(t, work, "a.txt", "a\n")
+	runGit(t, work, "add", ".")
+	runGit(t, work, "commit", "-qm", "a")
+	runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
+	seedTag(t, work, "v1.0.0")
+	_, assetID := seedRelease(t, database, dataDir, "pub", "v1.0.0")
+
+	view, err := owner.Get(httpServer.URL + "/josie/pub/releases/v1.0.0")
+	if err != nil {
+		t.Fatalf("GET release: %v", err)
+	}
+	body := readAll(t, view)
+	for _, want := range []string{"First release", "<strong>bold</strong>", "artifact.bin"} {
+		if !strings.Contains(body, want) {
+			t.Errorf("release view missing %q: %s", want, body)
+		}
+	}
+
+	list, _ := (&http.Client{}).Get(httpServer.URL + "/josie/pub/releases")
+	body = readAll(t, list)
+	if !strings.Contains(body, "First release") {
+		t.Errorf("release list missing the release: %s", body)
+	}
+	if strings.Contains(body, "Tags without a release") {
+		t.Errorf("released tag still listed as unreleased: %s", body)
+	}
+
+	download, err := owner.Get(httpServer.URL + "/josie/pub/releases/download/" + strconv.FormatInt(assetID, 10) + "/artifact.bin")
+	if err != nil {
+		t.Fatalf("download: %v", err)
+	}
+	content := readAll(t, download)
+	if download.StatusCode != http.StatusOK || content != "hello world" {
+		t.Errorf("download = %d %q, want 200 hello world", download.StatusCode, content)
+	}
+	if cd := download.Header.Get("Content-Disposition"); !strings.HasPrefix(cd, "attachment") {
+		t.Errorf("Content-Disposition = %q, want attachment", cd)
+	}
+	if download.Header.Get("X-Content-Type-Options") != "nosniff" {
+		t.Errorf("missing nosniff on download")
+	}
+}
+
+func TestReleaseDeleteOwnerOnly(t *testing.T) {
+	httpServer, database, dataDir := newTestServer(t)
+	createRepo(t, noFollow(newLoggedInClient(t, httpServer)), httpServer, "pub", "public")
+	seedRelease(t, database, dataDir, "pub", "v1")
+
+	addUser(t, database, "mallory", "pw")
+	mallory := noFollow(loginAs(t, httpServer, "mallory", "pw"))
+	resp, err := mallory.Post(httpServer.URL+"/josie/pub/releases/v1/delete", "", nil)
+	if err != nil {
+		t.Fatalf("mallory delete: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusNotFound {
+		t.Errorf("non-owner delete = %d, want 404", resp.StatusCode)
+	}
+}
+
+func TestDeleteReleaseRemovesAssets(t *testing.T) {
+	httpServer, database, dataDir := newTestServer(t)
+	owner := noFollow(newLoggedInClient(t, httpServer))
+	createRepo(t, owner, httpServer, "pub", "public")
+	releaseID, _ := seedRelease(t, database, dataDir, "pub", "v1")
+
+	repo, _ := db.GetRepoByName(database, "josie", "pub")
+	release, _ := db.GetReleaseByTag(database, repo.ID, "v1")
+	assets, _ := db.ListReleaseAssets(database, release.ID)
+	if len(assets) != 1 {
+		t.Fatalf("assets = %+v, want one", assets)
+	}
+	storedPath := filepath.Join(dataDir, "uploads", "releases", strconv.FormatInt(releaseID, 10), assets[0].StoredName)
+	if _, err := os.Stat(storedPath); err != nil {
+		t.Fatalf("stored asset missing before delete: %v", err)
+	}
+
+	resp, err := owner.Post(httpServer.URL+"/josie/pub/releases/v1/delete", "", nil)
+	if err != nil {
+		t.Fatalf("delete release: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusSeeOther {
+		t.Fatalf("delete release = %d, want 303", resp.StatusCode)
+	}
+	if _, err := db.GetReleaseByTag(database, repo.ID, "v1"); !errors.Is(err, db.ErrNotFound) {
+		t.Errorf("release after delete err = %v, want not found", err)
+	}
+	if _, err := os.Stat(storedPath); !os.IsNotExist(err) {
+		t.Errorf("stored asset still present after delete: %v", err)
+	}
+	view, _ := owner.Get(httpServer.URL + "/josie/pub/releases/v1")
+	readAll(t, view)
+	if view.StatusCode != http.StatusNotFound {
+		t.Errorf("release view after delete = %d, want 404", view.StatusCode)
+	}
+}
+
+// TestReleaseDownloadPrivateNeedsOwner checks the download follows the repo
+// visibility gate.
+func TestReleaseDownloadPrivateNeedsOwner(t *testing.T) {
+	httpServer, database, dataDir := newTestServer(t)
+	createRepo(t, noFollow(newLoggedInClient(t, httpServer)), httpServer, "sec", "private")
+	_, assetID := seedRelease(t, database, dataDir, "sec", "v1")
+
+	anon := noFollow(&http.Client{})
+	download, err := anon.Get(httpServer.URL + "/josie/sec/releases/download/" + strconv.FormatInt(assetID, 10) + "/secret.bin")
+	if err != nil {
+		t.Fatalf("anon download: %v", err)
+	}
+	readAll(t, download)
+	if download.StatusCode != http.StatusNotFound {
+		t.Errorf("anon private download = %d, want 404 (no existence oracle)", download.StatusCode)
+	}
+}
diff --git a/internal/web/repo.go b/internal/web/repo.go
new file mode 100644
index 0000000..16a36aa
--- /dev/null
+++ b/internal/web/repo.go
@@ -0,0 +1,102 @@
+package web
+
+import (
+	"errors"
+	"fmt"
+	"log"
+	"net/http"
+	"os"
+	"path/filepath"
+	"regexp"
+
+	"git.josie-c.com/josie/simplegit/internal/db"
+	"git.josie-c.com/josie/simplegit/internal/git"
+)
+
+// Repo names become URL path segments (/user/repo), same policy as usernames.
+var repoNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]*$`)
+
+type newRepoData struct {
+	Username    string
+	Name        string
+	Description string
+	Visibility  string
+	Error       string
+}
+
+type createdData struct {
+	Username string
+	RepoName string
+	PushURL  string
+}
+
+func (s *Server) handleNewRepoForm(w http.ResponseWriter, r *http.Request) {
+	user := requireUser(w, r)
+	if user == nil {
+		return
+	}
+	s.render(w, "new.html", http.StatusOK, newRepoData{Username: user.Username, Visibility: visibilityPrivate})
+}
+
+// Repo creation is owner-only: anonymous visitors are redirected to the
+// login page, so unauthenticated traffic can never materialize repos (or
+// their on-disk directories) on the server.
+func (s *Server) handleCreateRepo(w http.ResponseWriter, r *http.Request) {
+	user := requireUser(w, r)
+	if user == nil {
+		return
+	}
+	r.Body = http.MaxBytesReader(w, r.Body, loginFormMax)
+	if err := r.ParseForm(); err != nil {
+		http.Error(w, "bad form", http.StatusBadRequest)
+		return
+	}
+	// Truncate before validating so an oversized form value can never reach
+	// the error page or the log unbounded.
+	name := formText(r, "name", maxNameLen+1)
+	description := formText(r, "description", maxTitleLen)
+	visibility := r.FormValue("visibility")
+	if visibility != visibilityPublic {
+		visibility = visibilityPrivate
+	}
+	fail := func(status int, msg string) {
+		log.Printf("web: create repo %q: %s", name, msg)
+		s.render(w, "new.html", status, newRepoData{
+			Username: user.Username,
+			Name:     name, Description: description, Visibility: visibility, Error: msg,
+		})
+	}
+
+	if len(name) > maxNameLen || !repoNamePattern.MatchString(name) {
+		fail(http.StatusUnprocessableEntity, fmt.Sprintf("invalid repository name %q", name))
+		return
+	}
+	if _, err := db.GetRepoByName(s.database, user.Username, name); err == nil {
+		fail(http.StatusUnprocessableEntity, "you already have a repository with that name")
+		return
+	} else if !errors.Is(err, db.ErrNotFound) {
+		s.internalError(w, r, err)
+		return
+	}
+
+	// DB row first, bare repo second: if git fails we roll the row back,
+	// whereas a stray directory from a failed run would shadow a future create.
+	repo, err := db.CreateRepo(s.database, user.ID, name, description, visibility)
+	if err != nil {
+		fail(http.StatusInternalServerError, "could not create the repository")
+		return
+	}
+	repoPath := filepath.Join(s.cfg.DataDir, "repos", user.Username, repo.Name+".git")
+	if err := git.InitBare(repoPath, repo.DefaultBranch); err != nil {
+		log.Printf("web: git init %s: %v", repoPath, err)
+		if err := db.DeleteRepo(s.database, repo.ID); err != nil {
+			log.Printf("web: rollback repo %d: %v", repo.ID, err)
+		}
+		os.RemoveAll(repoPath)
+		fail(http.StatusInternalServerError, "could not initialize the repository on disk")
+		return
+	}
+
+	pushURL := cloneURL(s.cfg.BaseURL, user.Username, repo.Name)
+	s.render(w, "created.html", http.StatusOK, createdData{Username: user.Username, RepoName: repo.Name, PushURL: pushURL})
+}
diff --git a/internal/web/repo_settings.go b/internal/web/repo_settings.go
new file mode 100644
index 0000000..36d9161
--- /dev/null
+++ b/internal/web/repo_settings.go
@@ -0,0 +1,156 @@
+package web
+
+import (
+	"errors"
+	"fmt"
+	"log"
+	"net/http"
+	"os"
+	"path/filepath"
+
+	"git.josie-c.com/josie/simplegit/internal/db"
+)
+
+type repoSettingsData struct {
+	navData
+	CloneURL string
+	Error    string
+}
+
+// ownerRepo resolves a repo page and requires the caller to be its owner,
+// regardless of visibility. On failure it has written the response.
+func (s *Server) ownerRepo(w http.ResponseWriter, r *http.Request) (db.Repo, string, *db.User, bool) {
+	repo, repoPath, user, ok := s.resolveRepo(w, r)
+	if !ok {
+		return db.Repo{}, "", nil, false
+	}
+	if !isOwner(user, repo) {
+		if user == nil {
+			http.Redirect(w, r, "/login", http.StatusSeeOther)
+		} else {
+			http.NotFound(w, r)
+		}
+		return db.Repo{}, "", nil, false
+	}
+	return repo, repoPath, user, true
+}
+
+func (s *Server) repoView(r *http.Request, repo db.Repo, user *db.User) repoSettingsData {
+	return repoSettingsData{
+		navData:  nav(r, repo, user, "settings"),
+		CloneURL: cloneURL(s.cfg.BaseURL, r.PathValue("user"), repo.Name),
+	}
+}
+
+// handleRepoSettings renders the per-repo settings page.
+func (s *Server) handleRepoSettings(w http.ResponseWriter, r *http.Request) {
+	repo, _, user, ok := s.ownerRepo(w, r)
+	if !ok {
+		return
+	}
+	s.render(w, "repo_settings.html", http.StatusOK, s.repoView(r, repo, user))
+}
+
+// handleRepoVisibility toggles a repo between public and private.
+func (s *Server) handleRepoVisibility(w http.ResponseWriter, r *http.Request) {
+	repo, _, _, ok := s.ownerRepo(w, r)
+	if !ok {
+		return
+	}
+	r.Body = http.MaxBytesReader(w, r.Body, loginFormMax)
+	if err := r.ParseForm(); err != nil {
+		http.Error(w, "bad form", http.StatusBadRequest)
+		return
+	}
+	visibility := r.FormValue("visibility")
+	if visibility != visibilityPublic {
+		visibility = visibilityPrivate
+	}
+	if err := db.UpdateRepoVisibility(s.database, repo.ID, visibility); err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	http.Redirect(w, r, repoBasePath(r)+"/settings", http.StatusSeeOther)
+}
+
+// handleRepoRename moves the bare directory and updates the row.
+func (s *Server) handleRepoRename(w http.ResponseWriter, r *http.Request) {
+	repo, repoPath, user, ok := s.ownerRepo(w, r)
+	if !ok {
+		return
+	}
+	r.Body = http.MaxBytesReader(w, r.Body, loginFormMax)
+	if err := r.ParseForm(); err != nil {
+		http.Error(w, "bad form", http.StatusBadRequest)
+		return
+	}
+	owner := r.PathValue("user")
+	name := formText(r, "name", maxNameLen+1)
+	fail := func(msg string) {
+		data := s.repoView(r, repo, user)
+		data.Error = msg
+		s.render(w, "repo_settings.html", http.StatusUnprocessableEntity, data)
+	}
+	if name == repo.Name {
+		http.Redirect(w, r, repoBasePath(r)+"/settings", http.StatusSeeOther)
+		return
+	}
+	if len(name) > maxNameLen || !repoNamePattern.MatchString(name) {
+		fail(fmt.Sprintf("invalid repository name %q", name))
+		return
+	}
+	if _, err := db.GetRepoByName(s.database, owner, name); err == nil {
+		fail("you already have a repository with that name")
+		return
+	} else if !errors.Is(err, db.ErrNotFound) {
+		s.internalError(w, r, err)
+		return
+	}
+
+	newPath := filepath.Join(s.cfg.DataDir, "repos", owner, name+".git")
+	if err := os.Rename(repoPath, newPath); err != nil {
+		log.Printf("web: rename repo %s: %v", repoPath, err)
+		fail("could not move the repository on disk")
+		return
+	}
+	if err := db.RenameRepo(s.database, repo.ID, name); err != nil {
+		log.Printf("web: rename repo row %d: %v", repo.ID, err)
+		if rollbackErr := os.Rename(newPath, repoPath); rollbackErr != nil {
+			log.Printf("web: rename rollback %s: %v", newPath, rollbackErr)
+		}
+		s.internalError(w, r, err)
+		return
+	}
+	http.Redirect(w, r, "/"+owner+"/"+name+"/settings", http.StatusSeeOther)
+}
+
+// handleRepoDelete removes the row, the bare directory, and any release
+// asset uploads (the DB rows cascade, but files on disk do not).
+func (s *Server) handleRepoDelete(w http.ResponseWriter, r *http.Request) {
+	repo, repoPath, _, ok := s.ownerRepo(w, r)
+	if !ok {
+		return
+	}
+	releases, err := db.ListReleases(s.database, repo.ID)
+	if err != nil {
+		log.Printf("web: list releases for delete %d: %v", repo.ID, err)
+	}
+	if err := db.DeleteRepo(s.database, repo.ID); err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	if err := os.RemoveAll(repoPath); err != nil {
+		log.Printf("web: remove repo dir %s: %v", repoPath, err)
+	}
+	for _, release := range releases {
+		if err := os.RemoveAll(s.releaseUploadDir(release.ID)); err != nil {
+			log.Printf("web: remove release uploads %d: %v", release.ID, err)
+		}
+	}
+	http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+// repoBasePath is the canonical /{owner}/{repo} for the request.
+func repoBasePath(r *http.Request) string {
+	return "/" + r.PathValue("user") + "/" + r.PathValue("repo")
+}
diff --git a/internal/web/repo_settings_test.go b/internal/web/repo_settings_test.go
new file mode 100644
index 0000000..c0193e6
--- /dev/null
+++ b/internal/web/repo_settings_test.go
@@ -0,0 +1,122 @@
+package web
+
+import (
+	"net/http"
+	"net/url"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func TestRepoSettingsRequiresOwner(t *testing.T) {
+	httpServer, database, _ := newTestServer(t)
+	createRepo(t, newLoggedInClient(t, httpServer), httpServer, "pub", "public")
+	addUser(t, database, "mallory", "pw")
+
+	resp, err := noFollowClient().Get(httpServer.URL + "/josie/pub/settings")
+	if err != nil {
+		t.Fatalf("anonymous GET settings: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/login" {
+		t.Errorf("anonymous settings = %d %q, want 303 /login", resp.StatusCode, resp.Header.Get("Location"))
+	}
+
+	mallory := loginAs(t, httpServer, "mallory", "pw")
+	resp, err = mallory.Get(httpServer.URL + "/josie/pub/settings")
+	if err != nil {
+		t.Fatalf("non-owner GET settings: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusNotFound {
+		t.Errorf("non-owner settings = %d, want 404", resp.StatusCode)
+	}
+
+	owner := newLoggedInClient(t, httpServer)
+	resp, err = owner.Get(httpServer.URL + "/josie/pub/settings")
+	if err != nil {
+		t.Fatalf("owner GET settings: %v", err)
+	}
+	if body := readAll(t, resp); resp.StatusCode != http.StatusOK || !strings.Contains(body, "visibility") {
+		t.Errorf("owner settings = %d, body %q", resp.StatusCode, body)
+	}
+}
+
+func TestRepoSettingsVisibilityRenameDelete(t *testing.T) {
+	httpServer, _, dataDir := newTestServer(t)
+	owner := newLoggedInClient(t, httpServer)
+	createRepo(t, owner, httpServer, "pub", "public")
+	seedFiles(t, dataDir, "pub")
+
+	// Visibility: public -> private hides it from anonymous readers.
+	resp, err := owner.PostForm(httpServer.URL+"/josie/pub/settings/visibility", url.Values{"visibility": {"private"}})
+	if err != nil {
+		t.Fatalf("set private: %v", err)
+	}
+	readAll(t, resp)
+	anon, err := noFollowClient().Get(httpServer.URL + "/josie/pub")
+	if err != nil {
+		t.Fatalf("anon read after private: %v", err)
+	}
+	readAll(t, anon)
+	if anon.StatusCode != http.StatusNotFound {
+		t.Errorf("anon read private repo = %d, want 404 (no existence oracle)", anon.StatusCode)
+	}
+
+	// Rename moves the directory and the row.
+	resp, err = owner.PostForm(httpServer.URL+"/josie/pub/settings/rename", url.Values{"name": {"renamed"}})
+	if err != nil {
+		t.Fatalf("rename: %v", err)
+	}
+	readAll(t, resp)
+	if _, err := os.Stat(filepath.Join(dataDir, "repos", "josie", "pub.git")); !os.IsNotExist(err) {
+		t.Errorf("old repo dir still present (err %v)", err)
+	}
+	if _, err := os.Stat(filepath.Join(dataDir, "repos", "josie", "renamed.git")); err != nil {
+		t.Errorf("new repo dir missing: %v", err)
+	}
+	resp, err = owner.Get(httpServer.URL + "/josie/renamed")
+	if err != nil {
+		t.Fatalf("owner GET renamed: %v", err)
+	}
+	if body := readAll(t, resp); resp.StatusCode != http.StatusOK || !strings.Contains(body, "README.md") {
+		t.Errorf("renamed repo home = %d, body %q", resp.StatusCode, body)
+	}
+	resp, err = (&http.Client{}).Get(httpServer.URL + "/josie/pub")
+	if err != nil {
+		t.Fatalf("GET old name: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusNotFound {
+		t.Errorf("old repo URL = %d, want 404", resp.StatusCode)
+	}
+
+	// Duplicate rename is rejected.
+	createRepo(t, owner, httpServer, "other", "private")
+	resp, err = owner.PostForm(httpServer.URL+"/josie/renamed/settings/rename", url.Values{"name": {"other"}})
+	if err != nil {
+		t.Fatalf("duplicate rename: %v", err)
+	}
+	if body := readAll(t, resp); resp.StatusCode != http.StatusUnprocessableEntity || !strings.Contains(body, "already have a repository") {
+		t.Errorf("duplicate rename = %d, body %q", resp.StatusCode, body)
+	}
+
+	// Delete removes the directory and the row.
+	resp, err = owner.PostForm(httpServer.URL+"/josie/other/settings/delete", nil)
+	if err != nil {
+		t.Fatalf("delete: %v", err)
+	}
+	readAll(t, resp)
+	if _, err := os.Stat(filepath.Join(dataDir, "repos", "josie", "other.git")); !os.IsNotExist(err) {
+		t.Errorf("deleted repo dir still present (err %v)", err)
+	}
+	resp, err = owner.Get(httpServer.URL + "/josie/other")
+	if err != nil {
+		t.Fatalf("GET deleted repo: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusNotFound {
+		t.Errorf("deleted repo URL = %d, want 404", resp.StatusCode)
+	}
+}
diff --git a/internal/web/repo_test.go b/internal/web/repo_test.go
new file mode 100644
index 0000000..8f60af4
--- /dev/null
+++ b/internal/web/repo_test.go
@@ -0,0 +1,128 @@
+package web
+
+import (
+	"net/http"
+	"net/url"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"git.josie-c.com/josie/simplegit/internal/db"
+)
+
+func TestNewRepoFormRequiresLogin(t *testing.T) {
+	httpServer, _, _ := newTestServer(t)
+	client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
+		return http.ErrUseLastResponse
+	}}
+	resp, err := client.Get(httpServer.URL + "/new")
+	if err != nil {
+		t.Fatalf("GET /new: %v", err)
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusSeeOther {
+		t.Errorf("status = %d, want 303", resp.StatusCode)
+	}
+	if resp.Header.Get("Location") != "/login" {
+		t.Errorf("Location = %q, want /login", resp.Header.Get("Location"))
+	}
+
+	loggedIn := newLoggedInClient(t, httpServer)
+	resp, err = loggedIn.Get(httpServer.URL + "/new")
+	if err != nil {
+		t.Fatalf("GET /new signed in: %v", err)
+	}
+	body := readAll(t, resp)
+	if resp.StatusCode != http.StatusOK || !strings.Contains(body, `name="name"`) {
+		t.Errorf("signed-in GET /new = %d, form missing: %q", resp.StatusCode, body)
+	}
+}
+
+func TestCreateRepoFlow(t *testing.T) {
+	httpServer, database, dataDir := newTestServer(t)
+	client := newLoggedInClient(t, httpServer)
+
+	resp, err := client.PostForm(httpServer.URL+"/new", url.Values{
+		"name": {"my-repo"}, "description": {"a test repo"}, "visibility": {"public"},
+	})
+	if err != nil {
+		t.Fatalf("POST /new: %v", err)
+	}
+	body := readAll(t, resp)
+	if resp.StatusCode != http.StatusOK {
+		t.Errorf("status = %d, want 200", resp.StatusCode)
+	}
+	if !strings.Contains(body, "git remote add origin") || !strings.Contains(body, "/josie/my-repo.git") {
+		t.Errorf("no push instructions: %q", body)
+	}
+
+	repo, err := db.GetRepoByName(database, "josie", "my-repo")
+	if err != nil {
+		t.Fatalf("GetRepoByName: %v", err)
+	}
+	if repo.Visibility != "public" || repo.Description != "a test repo" {
+		t.Errorf("repo = %+v, want public / %q stored", repo, "a test repo")
+	}
+	if repo.PushedAt.Valid {
+		t.Errorf("PushedAt = %d, want NULL before first push", repo.PushedAt.Int64)
+	}
+
+	head, err := os.ReadFile(filepath.Join(dataDir, "repos", "josie", "my-repo.git", "HEAD"))
+	if err != nil {
+		t.Fatalf("bare repo HEAD missing: %v", err)
+	}
+	if !strings.Contains(string(head), "ref: refs/heads/"+repo.DefaultBranch) {
+		t.Errorf("HEAD = %q, want default branch %q", head, repo.DefaultBranch)
+	}
+}
+
+func TestCreateRepoRejectsBadNames(t *testing.T) {
+	httpServer, _, _ := newTestServer(t)
+	client := newLoggedInClient(t, httpServer)
+	for _, name := range []string{"bad name", "ünicode", "", strings.Repeat("a", maxNameLen+1)} {
+		resp, err := client.PostForm(httpServer.URL+"/new", url.Values{"name": {name}})
+		if err != nil {
+			t.Fatalf("POST /new %q: %v", name, err)
+		}
+		body := readAll(t, resp)
+		if resp.StatusCode != http.StatusUnprocessableEntity {
+			t.Errorf("name %q: status = %d, want 422", name, resp.StatusCode)
+		}
+		if !strings.Contains(body, "invalid repository name") {
+			t.Errorf("name %q: no error shown: %q", name, body)
+		}
+	}
+}
+
+func TestCreateRepoRejectsDuplicate(t *testing.T) {
+	httpServer, database, _ := newTestServer(t)
+	client := newLoggedInClient(t, httpServer)
+	resp, err := client.PostForm(httpServer.URL+"/new", url.Values{"name": {"dup"}})
+	if err != nil {
+		t.Fatalf("first POST /new: %v", err)
+	}
+	readAll(t, resp)
+
+	resp, err = client.PostForm(httpServer.URL+"/new", url.Values{"name": {"dup"}})
+	if err != nil {
+		t.Fatalf("second POST /new: %v", err)
+	}
+	body := readAll(t, resp)
+	if resp.StatusCode != http.StatusUnprocessableEntity {
+		t.Errorf("status = %d, want 422", resp.StatusCode)
+	}
+	if !strings.Contains(body, "already have a repository") {
+		t.Errorf("no duplicate error shown: %q", body)
+	}
+
+	var count int
+	if err := database.QueryRow(
+		`SELECT count(*) FROM repos WHERE name = ?`, "dup",
+	).Scan(&count); err != nil {
+		t.Fatalf("count repos: %v", err)
+	}
+	if count != 1 {
+		t.Errorf("repos named dup = %d, want 1", count)
+	}
+}
diff --git a/internal/web/repohome.go b/internal/web/repohome.go
new file mode 100644
index 0000000..a217c93
--- /dev/null
+++ b/internal/web/repohome.go
@@ -0,0 +1,230 @@
+package web
+
+import (
+	"html/template"
+	"net/http"
+	"net/url"
+	"path"
+	"strings"
+
+	"git.josie-c.com/josie/simplegit/internal/db"
+	"git.josie-c.com/josie/simplegit/internal/git"
+	"git.josie-c.com/josie/simplegit/internal/render"
+)
+
+// readmeCandidates are tried in order at the repo root; first hit wins.
+var readmeCandidates = []string{"README.md", "README.markdown", "README.mkd", "README"}
+
+const (
+	readmeLimit  = 1 << 20
+	licenseLimit = 64 << 10
+)
+
+// treeNode is one entry in the Code tab's file tree: a directory (Children,
+// no Href) or a file (Href to its blob view).
+type treeNode struct {
+	Name     string
+	Href     string
+	Children []*treeNode
+}
+
+// buildFileTree turns a recursive path list into nested nodes; directories
+// materialize on demand in git's leaf order.
+func buildFileTree(paths []string, href func(string) string) []*treeNode {
+	var roots []*treeNode
+	dirs := map[string]*treeNode{}
+	for _, p := range paths {
+		parts := strings.Split(p, "/")
+		parent := &roots
+		dirPath := ""
+		for i, part := range parts {
+			if i == len(parts)-1 {
+				*parent = append(*parent, &treeNode{Name: part, Href: href(p)})
+				continue
+			}
+			if dirPath != "" {
+				dirPath += "/"
+			}
+			dirPath += part
+			node, ok := dirs[dirPath]
+			if !ok {
+				node = &treeNode{Name: part + "/"}
+				dirs[dirPath] = node
+				*parent = append(*parent, node)
+			}
+			parent = &node.Children
+		}
+	}
+	return roots
+}
+
+// buildTree lists ref's files and nests them for the Code tab sidebar. A
+// listing failure (empty or unreadable repo) just leaves the tree empty.
+func (s *Server) buildTree(r *http.Request, repo db.Repo, ref string) []*treeNode {
+	paths, err := git.LsTreePaths(s.repoPathFor(r.PathValue("user"), repo), ref)
+	if err != nil {
+		return nil
+	}
+	hrefBase := "/" + r.PathValue("user") + "/" + repo.Name + "/blob/" + url.PathEscape(ref) + "/"
+	return buildFileTree(paths, func(p string) string { return hrefBase + escapePath(p) })
+}
+
+type repoHomeData struct {
+	navData
+	Description   string
+	Branch        string // the ref being viewed
+	DefaultBranch string
+	CloneURL      string
+	Empty         bool
+	Tree          []*treeNode
+	Readme        template.HTML
+	LicenseName   string
+	LicenseHref   string
+	CommitCount   int
+	BranchCount   int
+	TagCount      int
+	Branches      []string
+}
+
+func (s *Server) baseRepoData(r *http.Request, repo db.Repo, user *db.User) repoHomeData {
+	return repoHomeData{
+		navData:       nav(r, repo, user, "code"),
+		Description:   repo.Description,
+		Branch:        repo.DefaultBranch,
+		DefaultBranch: repo.DefaultBranch,
+		CloneURL:      cloneURL(s.cfg.BaseURL, r.PathValue("user"), repo.Name),
+	}
+}
+
+func (s *Server) handleRepoHome(w http.ResponseWriter, r *http.Request) {
+	repo, repoPath, user, ok := s.resolveRepo(w, r)
+	if !ok {
+		return
+	}
+	data := s.baseRepoData(r, repo, user)
+
+	exists, err := git.RefExists(repoPath, repo.DefaultBranch)
+	if err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	if !exists {
+		data.Empty = true
+		s.render(w, "repo.html", http.StatusOK, data)
+		return
+	}
+
+	entries, err := git.LsTree(repoPath, repo.DefaultBranch, "")
+	if err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	s.renderCodeTab(w, r, repo, repoPath, user, repo.DefaultBranch, entries)
+}
+
+// attachTree builds the recursive file tree for the Code tab.
+func (s *Server) attachTree(data *repoHomeData, repoPath string) {
+	paths, err := git.LsTreePaths(repoPath, data.Branch)
+	if err != nil {
+		return
+	}
+	base := "/" + data.Owner + "/" + data.RepoName + "/blob/" + url.PathEscape(data.Branch) + "/"
+	data.Tree = buildFileTree(paths, func(p string) string { return base + escapePath(p) })
+}
+
+// attachRepoMeta fills the Code tab's summary: commit/branch/tag counts and
+// the branch list for the dropdown.
+func (s *Server) attachRepoMeta(data *repoHomeData, repoPath string) {
+	if n, err := git.CommitCount(repoPath, data.Branch); err == nil {
+		data.CommitCount = n
+	}
+	if branches, err := git.Branches(repoPath); err == nil {
+		data.Branches = branches
+		data.BranchCount = len(branches)
+	}
+	if tags, err := git.Tags(repoPath); err == nil {
+		data.TagCount = len(tags)
+	}
+}
+
+// attachReadme renders the first README candidate at the repo root.
+func (s *Server) attachReadme(data *repoHomeData, repoPath string, entries []git.TreeEntry) {
+	var readme string
+	for _, candidate := range readmeCandidates {
+		for _, entry := range entries {
+			if entry.Type == "blob" && entry.Path == candidate {
+				readme = candidate
+				break
+			}
+		}
+		if readme != "" {
+			break
+		}
+	}
+	if readme == "" {
+		return
+	}
+	source, err := git.ShowFile(repoPath, data.Branch, readme, readmeLimit)
+	if err != nil || len(source) > readmeLimit {
+		return
+	}
+	if strings.HasPrefix(strings.ToLower(readme), "readme.") {
+		html, err := render.Markdown(source)
+		if err != nil {
+			return
+		}
+		data.Readme = template.HTML(html)
+		return
+	}
+	data.Readme = template.HTML("<pre>" + template.HTMLEscapeString(string(source)) + "</pre>")
+}
+
+// attachLicense sniffs the first LICENSE*/COPYING* file at the repo root and
+// links the license summary box to it.
+func (s *Server) attachLicense(data *repoHomeData, repoPath string, entries []git.TreeEntry) {
+	for _, entry := range entries {
+		base := strings.ToUpper(path.Base(entry.Path))
+		if entry.Type != "blob" || !strings.HasPrefix(base, "LICENSE") && !strings.HasPrefix(base, "COPYING") {
+			continue
+		}
+		source, err := git.ShowFile(repoPath, data.Branch, entry.Path, licenseLimit)
+		if err != nil {
+			return
+		}
+		if len(source) > licenseLimit {
+			source = source[:licenseLimit]
+		}
+		data.LicenseName = detectLicense(string(source))
+		data.LicenseHref = "/" + data.Owner + "/" + data.RepoName + "/blob/" + escapePath(data.Branch+"/"+entry.Path)
+		return
+	}
+}
+
+// detectLicense is a deliberately dumb keyword sniff over the license
+// text; anything unrecognised but present is "Custom".
+func detectLicense(text string) string {
+	upper := strings.ToUpper(text)
+	switch {
+	case strings.Contains(upper, "MIT LICENSE"),
+		strings.Contains(upper, "PERMISSION IS HEREBY GRANTED, FREE OF CHARGE"):
+		return "MIT"
+	case strings.Contains(upper, "APACHE LICENSE, VERSION 2"):
+		return "Apache-2.0"
+	case strings.Contains(upper, "BSD 3-CLAUSE"), strings.Contains(upper, "BSD 3. CLAUSE"):
+		return "BSD-3-Clause"
+	case strings.Contains(upper, "BSD 2-CLAUSE"), strings.Contains(upper, "BSD 2. CLAUSE"):
+		return "BSD-2-Clause"
+	case strings.Contains(upper, "ISC LICENSE"):
+		return "ISC"
+	case strings.Contains(upper, "UNLICENSE"):
+		return "Unlicense"
+	case strings.Contains(upper, "GNU GENERAL PUBLIC LICENSE"):
+		return "GPL"
+	case strings.Contains(upper, "GNU LESSER GENERAL PUBLIC LICENSE"):
+		return "LGPL"
+	case strings.Contains(upper, "MOZILLA PUBLIC LICENSE"):
+		return "MPL"
+	default:
+		return "Custom"
+	}
+}
diff --git a/internal/web/repohome_test.go b/internal/web/repohome_test.go
new file mode 100644
index 0000000..be2ac6f
--- /dev/null
+++ b/internal/web/repohome_test.go
@@ -0,0 +1,144 @@
+package web
+
+import (
+	"net/http"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+// seedFiles gives a repo created via /new one commit on main.
+func seedFiles(t *testing.T, dataDir, name string) {
+	t.Helper()
+	bare := filepath.Join(dataDir, "repos", "josie", name+".git")
+	work := filepath.Join(t.TempDir(), "work")
+	cmd := exec.Command("git", "clone", "-q", bare, work)
+	if out, err := cmd.CombinedOutput(); err != nil {
+		t.Fatalf("clone seed: %v: %s", err, out)
+	}
+	write := func(rel, content string) {
+		full := filepath.Join(work, rel)
+		if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
+			t.Fatalf("mkdir: %v", err)
+		}
+		if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
+			t.Fatalf("write %s: %v", rel, err)
+		}
+	}
+	write("README.md", "# demo repo\n\nthe **readme** body\n")
+	write("LICENSE", "MIT License\n\nPermission is hereby granted, free of charge, to any person...\n")
+	write("hello.c", "int main(void) { return 0; }\n")
+	write("sub/note.txt", "note\n")
+	for _, args := range [][]string{
+		{"add", "."},
+		{"-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "seed"},
+		{"push", "-q", "origin", "main"},
+	} {
+		cmd := exec.Command("git", args...)
+		cmd.Dir = work
+		if out, err := cmd.CombinedOutput(); err != nil {
+			t.Fatalf("git %v: %v: %s", args, err, out)
+		}
+	}
+}
+
+func TestRepoHomePublic(t *testing.T) {
+	httpServer, _, dataDir := newTestServer(t)
+	createRepo(t, newLoggedInClient(t, httpServer), httpServer, "pub", "public")
+	seedFiles(t, dataDir, "pub")
+
+	resp, err := (&http.Client{}).Get(httpServer.URL + "/josie/pub")
+	if err != nil {
+		t.Fatalf("GET /josie/pub: %v", err)
+	}
+	body := readAll(t, resp)
+	if resp.StatusCode != http.StatusOK {
+		t.Fatalf("status = %d, want 200: %q", resp.StatusCode, body)
+	}
+	for _, want := range []string{
+		"josie/pub", "README.md", "hello.c", "sub/",
+		"<strong>readme</strong>", "MIT", "/josie/pub.git",
+		"/josie/pub/blob/main/hello.c", "/josie/pub/blob/main/sub/note.txt",
+		`class="file-tree`, "✓ main", `value="http`,
+	} {
+		if !strings.Contains(body, want) {
+			t.Errorf("body lacks %q", want)
+		}
+	}
+}
+
+func TestRepoHomePrivate(t *testing.T) {
+	httpServer, _, dataDir := newTestServer(t)
+	loggedIn := newLoggedInClient(t, httpServer)
+	createRepo(t, loggedIn, httpServer, "sec", "private")
+	seedFiles(t, dataDir, "sec")
+
+	noFollow := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
+		return http.ErrUseLastResponse
+	}}
+	resp, err := noFollow.Get(httpServer.URL + "/josie/sec")
+	if err != nil {
+		t.Fatalf("anonymous GET private home: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusNotFound {
+		t.Errorf("anonymous status = %d, want 404 (no existence oracle)", resp.StatusCode)
+	}
+
+	resp, err = loggedIn.Get(httpServer.URL + "/josie/sec")
+	if err != nil {
+		t.Fatalf("signed-in GET private home: %v", err)
+	}
+	body := readAll(t, resp)
+	if resp.StatusCode != http.StatusOK || !strings.Contains(body, "README.md") {
+		t.Errorf("signed-in status = %d, want 200 with listing", resp.StatusCode)
+	}
+}
+
+func TestRepoHomeEmptyRepoShowsInstructions(t *testing.T) {
+	httpServer, _, _ := newTestServer(t)
+	loggedIn := newLoggedInClient(t, httpServer)
+	createRepo(t, loggedIn, httpServer, "fresh", "private")
+
+	resp, err := loggedIn.Get(httpServer.URL + "/josie/fresh")
+	if err != nil {
+		t.Fatalf("GET empty home: %v", err)
+	}
+	body := readAll(t, resp)
+	if !strings.Contains(body, "The repository is empty") || !strings.Contains(body, "git remote add origin") {
+		t.Errorf("empty repo page lacks push instructions: %q", body)
+	}
+}
+
+func TestRepoHomePrivateOwnerOnly(t *testing.T) {
+	httpServer, database, dataDir := newTestServer(t)
+	createRepo(t, newLoggedInClient(t, httpServer), httpServer, "sec", "private")
+	seedFiles(t, dataDir, "sec")
+	addUser(t, database, "mallory", "pw")
+
+	mallory := loginAs(t, httpServer, "mallory", "pw")
+	for _, path := range []string{"/josie/sec", "/josie/sec/tree/main", "/josie/sec/blob/main/README.md"} {
+		resp, err := mallory.Get(httpServer.URL + path)
+		if err != nil {
+			t.Fatalf("GET %s as non-owner: %v", path, err)
+		}
+		readAll(t, resp)
+		if resp.StatusCode != http.StatusNotFound {
+			t.Errorf("non-owner GET %s = %d, want 404", path, resp.StatusCode)
+		}
+	}
+}
+
+func TestRepoHomeUnknown(t *testing.T) {
+	httpServer, _, _ := newTestServer(t)
+	resp, err := (&http.Client{}).Get(httpServer.URL + "/josie/nope")
+	if err != nil {
+		t.Fatalf("GET unknown: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusNotFound {
+		t.Errorf("status = %d, want 404", resp.StatusCode)
+	}
+}
diff --git a/internal/web/session.go b/internal/web/session.go
new file mode 100644
index 0000000..20e69d9
--- /dev/null
+++ b/internal/web/session.go
@@ -0,0 +1,81 @@
+package web
+
+import (
+	"context"
+	"net/http"
+	"strings"
+	"time"
+
+	"git.josie-c.com/josie/simplegit/internal/db"
+)
+
+const (
+	sessionCookieName = "session"
+	sessionDuration   = 30 * 24 * time.Hour
+)
+
+type contextKey int
+
+const userKey contextKey = iota
+
+// withUser resolves a valid session cookie into a *db.User on the
+// request context. Requests without a session pass through anonymous.
+func (s *Server) withUser(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		cookie, err := r.Cookie(sessionCookieName)
+		if err == nil && cookie.Value != "" {
+			session, err := db.GetSession(s.database, cookie.Value)
+			if err == nil {
+				user, err := db.GetUserByID(s.database, session.UserID)
+				if err == nil {
+					r = r.WithContext(context.WithValue(r.Context(), userKey, &user))
+				}
+			}
+		}
+		next.ServeHTTP(w, r)
+	})
+}
+
+// currentUser returns the authenticated user, or nil for anonymous requests.
+func currentUser(r *http.Request) *db.User {
+	user, _ := r.Context().Value(userKey).(*db.User)
+	return user
+}
+
+// sessionCookie builds the session cookie. TLS terminates at the reverse
+// proxy, so the Secure flag follows the configured base URL's scheme.
+func (s *Server) sessionCookie(value string, maxAge time.Duration) *http.Cookie {
+	return &http.Cookie{
+		Name:     sessionCookieName,
+		Value:    value,
+		Path:     "/",
+		MaxAge:   int(maxAge.Seconds()),
+		HttpOnly: true,
+		SameSite: http.SameSiteLaxMode,
+		Secure:   strings.HasPrefix(s.cfg.BaseURL, "https://"),
+	}
+}
+
+// navData is the model shared by repo pages — who is viewing, which repo,
+// which tab. Templates read the promoted fields unchanged.
+type navData struct {
+	Username   string
+	Owner      string
+	RepoName   string
+	Visibility string
+	Active     string
+}
+
+// nav builds navData for a repo page from the request and viewer.
+func nav(r *http.Request, repo db.Repo, user *db.User, active string) navData {
+	n := navData{
+		Owner:      r.PathValue("user"),
+		RepoName:   repo.Name,
+		Visibility: repo.Visibility,
+		Active:     active,
+	}
+	if user != nil {
+		n.Username = user.Username
+	}
+	return n
+}
diff --git a/internal/web/settings.go b/internal/web/settings.go
new file mode 100644
index 0000000..e5bbdf6
--- /dev/null
+++ b/internal/web/settings.go
@@ -0,0 +1,165 @@
+package web
+
+import (
+	"errors"
+	"net/http"
+	"time"
+
+	"git.josie-c.com/josie/simplegit/internal/auth"
+	"git.josie-c.com/josie/simplegit/internal/db"
+)
+
+// tokenView is one row of the token list, with a formatted date.
+type tokenView struct {
+	ID      int64
+	Name    string
+	Hint    string
+	Created string
+}
+
+type settingsData struct {
+	Username      string
+	Tokens        []tokenView
+	NewToken      string // plaintext, shown exactly once
+	PasswordError string
+	PasswordOK    bool
+}
+
+func tokenViews(tokens []db.Token) []tokenView {
+	views := make([]tokenView, 0, len(tokens))
+	for _, tok := range tokens {
+		views = append(views, tokenView{
+			ID: tok.ID, Name: tok.Name, Hint: tok.Hint,
+			Created: time.Unix(tok.CreatedAt, 0).Format("2006-01-02"),
+		})
+	}
+	return views
+}
+
+// settingsView loads the page model for a user; a missing token list is
+// non-fatal (the page still renders the password form).
+func (s *Server) settingsView(user *db.User) settingsData {
+	data := settingsData{Username: user.Username}
+	if tokens, err := db.ListTokens(s.database, user.ID); err == nil {
+		data.Tokens = tokenViews(tokens)
+	}
+	return data
+}
+
+// requireUser redirects anonymous callers to the login page and reports
+// whether the request may continue.
+func requireUser(w http.ResponseWriter, r *http.Request) *db.User {
+	user := currentUser(r)
+	if user == nil {
+		http.Redirect(w, r, "/login", http.StatusSeeOther)
+		return nil
+	}
+	return user
+}
+
+// handleSettings shows the account page: token list and password change.
+func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
+	user := requireUser(w, r)
+	if user == nil {
+		return
+	}
+	s.render(w, "settings.html", http.StatusOK, s.settingsView(user))
+}
+
+// handleCreateToken mints a token and re-renders the page so the plaintext
+// is visible once.
+func (s *Server) handleCreateToken(w http.ResponseWriter, r *http.Request) {
+	user := requireUser(w, r)
+	if user == nil {
+		return
+	}
+	r.Body = http.MaxBytesReader(w, r.Body, loginFormMax)
+	if err := r.ParseForm(); err != nil {
+		http.Error(w, "bad form", http.StatusBadRequest)
+		return
+	}
+	name := formText(r, "name", tokenLabelMax)
+	if name == "" {
+		name = "token"
+	}
+	plain, err := auth.NewAPIToken()
+	if err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	if _, err := db.CreateToken(s.database, user.ID, name, auth.TokenHint(plain), auth.HashToken(plain)); err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	data := s.settingsView(user)
+	data.NewToken = plain
+	s.render(w, "settings.html", http.StatusOK, data)
+}
+
+// handleDeleteToken revokes one of the caller's tokens.
+func (s *Server) handleDeleteToken(w http.ResponseWriter, r *http.Request) {
+	user := requireUser(w, r)
+	if user == nil {
+		return
+	}
+	id, ok := pathID(r)
+	if !ok {
+		http.NotFound(w, r)
+		return
+	}
+	if err := db.DeleteToken(s.database, user.ID, id); err != nil && !errors.Is(err, db.ErrNotFound) {
+		s.internalError(w, r, err)
+		return
+	}
+	http.Redirect(w, r, "/settings", http.StatusSeeOther)
+}
+
+// handleChangePassword verifies the current password, stores a new hash,
+// and signs out every other browser session.
+func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
+	user := requireUser(w, r)
+	if user == nil {
+		return
+	}
+	r.Body = http.MaxBytesReader(w, r.Body, loginFormMax)
+	if err := r.ParseForm(); err != nil {
+		http.Error(w, "bad form", http.StatusBadRequest)
+		return
+	}
+	current := r.FormValue("current_password")
+	next := r.FormValue("new_password")
+	confirm := r.FormValue("confirm_password")
+
+	fail := func(message string) {
+		data := s.settingsView(user)
+		data.PasswordError = message
+		s.render(w, "settings.html", http.StatusUnprocessableEntity, data)
+	}
+	switch {
+	case !auth.CheckPassword(user.PasswordHash, current):
+		fail("current password is incorrect")
+		return
+	case next == "":
+		fail("new password must not be empty")
+		return
+	case next != confirm:
+		fail("new passwords do not match")
+		return
+	}
+
+	hash, err := auth.HashPassword(next)
+	if err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	if err := db.UpdateUserPassword(s.database, user.ID, hash); err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	if cookie, err := r.Cookie(sessionCookieName); err == nil {
+		_ = db.DeleteOtherSessions(s.database, user.ID, cookie.Value)
+	}
+	data := s.settingsView(user)
+	data.PasswordOK = true
+	s.render(w, "settings.html", http.StatusOK, data)
+}
diff --git a/internal/web/settings_test.go b/internal/web/settings_test.go
new file mode 100644
index 0000000..0756c32
--- /dev/null
+++ b/internal/web/settings_test.go
@@ -0,0 +1,184 @@
+package web
+
+import (
+	"net/http"
+	"net/http/cookiejar"
+	"net/url"
+	"regexp"
+	"strings"
+	"testing"
+)
+
+func newJar(t *testing.T) http.CookieJar {
+	t.Helper()
+	jar, err := cookiejar.New(nil)
+	if err != nil {
+		t.Fatalf("cookiejar: %v", err)
+	}
+	return jar
+}
+
+var (
+	tokenPattern = regexp.MustCompile(`sg_[A-Za-z0-9_-]+`)
+	tokenIDPath  = regexp.MustCompile(`/settings/tokens/(\d+)/revoke`)
+)
+
+func TestSettingsRequiresLogin(t *testing.T) {
+	httpServer, _, _ := newTestServer(t)
+	noFollow := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
+		return http.ErrUseLastResponse
+	}}
+	resp, err := noFollow.Get(httpServer.URL + "/settings")
+	if err != nil {
+		t.Fatalf("anonymous GET /settings: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/login" {
+		t.Errorf("anonymous settings = %d %q, want 303 /login", resp.StatusCode, resp.Header.Get("Location"))
+	}
+}
+
+func TestChangePassword(t *testing.T) {
+	httpServer, _, _ := newTestServer(t)
+	josie := newLoggedInClient(t, httpServer)
+	other := newLoggedInClient(t, httpServer)
+	change := func(current, next, confirm string) string {
+		t.Helper()
+		resp, err := josie.PostForm(httpServer.URL+"/settings/password", url.Values{
+			"current_password": {current}, "new_password": {next}, "confirm_password": {confirm},
+		})
+		if err != nil {
+			t.Fatalf("POST /settings/password: %v", err)
+		}
+		return readAll(t, resp)
+	}
+
+	if body := change("wrong", "newpass", "newpass"); !strings.Contains(body, "current password is incorrect") {
+		t.Errorf("wrong current password: body = %q", body)
+	}
+	if body := change("hunter2", "newpass", "different"); !strings.Contains(body, "do not match") {
+		t.Errorf("mismatched confirm: body = %q", body)
+	}
+	if body := change("hunter2", "newpass", "newpass"); !strings.Contains(body, "password changed") {
+		t.Errorf("valid change: body = %q", body)
+	}
+
+	// The session that made the change stays signed in.
+	if resp, err := josie.Get(httpServer.URL + "/settings"); err != nil {
+		t.Fatalf("GET /settings after change: %v", err)
+	} else if readAll(t, resp); resp.StatusCode != http.StatusOK {
+		t.Errorf("current session after change = %d, want 200", resp.StatusCode)
+	}
+
+	// A different session is revoked.
+	noFollow := &http.Client{
+		Transport: other.Transport,
+		Jar:       other.Jar,
+		CheckRedirect: func(*http.Request, []*http.Request) error {
+			return http.ErrUseLastResponse
+		},
+	}
+	resp, err := noFollow.Get(httpServer.URL + "/settings")
+	if err != nil {
+		t.Fatalf("other session GET: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/login" {
+		t.Errorf("other session = %d %q, want 303 /login", resp.StatusCode, resp.Header.Get("Location"))
+	}
+
+	// Old password no longer works; the new one does.
+	fresh := &http.Client{Transport: httpServer.Client().Transport, Jar: newJar(t)}
+	resp, err = fresh.PostForm(httpServer.URL+"/login", url.Values{"username": {"josie"}, "password": {"hunter2"}})
+	if err != nil {
+		t.Fatalf("login old password: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusUnauthorized {
+		t.Errorf("old password login = %d, want 401", resp.StatusCode)
+	}
+	resp, err = fresh.PostForm(httpServer.URL+"/login", url.Values{"username": {"josie"}, "password": {"newpass"}})
+	if err != nil {
+		t.Fatalf("login new password: %v", err)
+	}
+	if body := readAll(t, resp); !strings.Contains(body, `href="/josie"`) {
+		t.Errorf("new password login rejected: %q", body)
+	}
+}
+
+func basicRefs(t *testing.T, httpServerURL, user, secret string) int {
+	t.Helper()
+	req, err := http.NewRequest("GET", httpServerURL+"/josie/sec.git/info/refs?service=git-upload-pack", nil)
+	if err != nil {
+		t.Fatalf("new request: %v", err)
+	}
+	req.SetBasicAuth(user, secret)
+	resp, err := (&http.Client{}).Do(req)
+	if err != nil {
+		t.Fatalf("basic refs: %v", err)
+	}
+	readAll(t, resp)
+	return resp.StatusCode
+}
+
+func TestTokenCreateUseRevoke(t *testing.T) {
+	httpServer, database, _ := newTestServer(t)
+	loggedIn := newLoggedInClient(t, httpServer)
+	createRepo(t, loggedIn, httpServer, "sec", "private")
+
+	resp, err := loggedIn.PostForm(httpServer.URL+"/settings/tokens", url.Values{"name": {"laptop"}})
+	if err != nil {
+		t.Fatalf("create token: %v", err)
+	}
+	body := readAll(t, resp)
+	if resp.StatusCode != http.StatusOK {
+		t.Fatalf("create token status = %d: %q", resp.StatusCode, body)
+	}
+	token := tokenPattern.FindString(body)
+	if token == "" {
+		t.Fatalf("response did not show a new token: %q", body)
+	}
+
+	list, err := loggedIn.Get(httpServer.URL + "/settings")
+	if err != nil {
+		t.Fatalf("GET /settings: %v", err)
+	}
+	listBody := readAll(t, list)
+	if !strings.Contains(listBody, "laptop") || !strings.Contains(listBody, token[:8]) {
+		t.Errorf("settings list lacks the token row: %q", listBody)
+	}
+	if strings.Contains(listBody, token) {
+		t.Error("full token secret leaked into the settings list")
+	}
+
+	if code := basicRefs(t, httpServer.URL, "josie", token); code != http.StatusOK {
+		t.Errorf("token as basic-auth password = %d, want 200", code)
+	}
+	if code := basicRefs(t, httpServer.URL, "josie", "sg_not-a-real-token"); code != http.StatusUnauthorized {
+		t.Errorf("bogus token = %d, want 401", code)
+	}
+
+	match := tokenIDPath.FindStringSubmatch(listBody)
+	if match == nil {
+		t.Fatalf("no revoke link in settings: %q", listBody)
+	}
+
+	// A different user must not be able to revoke someone else's token.
+	addUser(t, database, "mallory", "pw")
+	mallory := loginAs(t, httpServer, "mallory", "pw")
+	if resp, err := mallory.PostForm(httpServer.URL+"/settings/tokens/"+match[1]+"/revoke", nil); err == nil {
+		readAll(t, resp)
+	}
+	if code := basicRefs(t, httpServer.URL, "josie", token); code != http.StatusOK {
+		t.Errorf("token invalidated by a non-owner = %d, want still 200", code)
+	}
+
+	revoke, err := loggedIn.PostForm(httpServer.URL+"/settings/tokens/"+match[1]+"/revoke", nil)
+	if err != nil {
+		t.Fatalf("revoke token: %v", err)
+	}
+	readAll(t, revoke)
+	if code := basicRefs(t, httpServer.URL, "josie", token); code != http.StatusUnauthorized {
+		t.Errorf("revoked token = %d, want 401", code)
+	}
+}
diff --git a/internal/web/static/htmx.min.js b/internal/web/static/htmx.min.js
new file mode 100644
index 0000000..59937d7
--- /dev/null
+++ b/internal/web/static/htmx.min.js
@@ -0,0 +1 @@
+var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.4"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=he;Q.ajax=Rn;Q.find=u;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:hn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:o,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:ht,triggerEvent:he,triggerErrorEvent:fe,withExtensions:Ft};const r=["get","post","put","delete","patch"];const H=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function o(e,t){while(e&&!t(e)){e=c(e)}return e||null}function i(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;o(t,function(e){return!!(r=i(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function A(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function N(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(N(e)){const t=A(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){O(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function D(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;e<t.length;e++){n.push(t[e])}}return n}function se(t,n){if(t){for(let e=0;e<t.length;e++){n(t[e])}}}function X(e){const t=e.getBoundingClientRect();const n=t.top;const r=t.bottom;return n<window.innerHeight&&r>=0}function le(e){return e.getRootNode({composed:true})===document}function F(e){return e.trim().split(/\s+/)}function ce(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){O(e);return null}}function B(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function U(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return vn(ne().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function u(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return u(ne(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ne(),e)}}function E(){return window}function z(e,t){e=y(e);if(t){E().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function $(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function f(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ue(y(e));if(!e){return}if(n){E().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ue(y(e));if(!r){return}if(n){E().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function Z(e,t){e=y(e);se(e.parentElement.children,function(e){G(e,t)});K(ue(e),t)}function g(e,t){e=ue(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&ue(c(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function ge(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function p(t,r,n){if(r.indexOf("global ")===0){return p(t,r.slice(7),true)}t=y(t);const o=[];{let t=0;let n=0;for(let e=0;e<r.length;e++){const l=r[e];if(l===","&&t===0){o.push(r.substring(n,e));n=e+1;continue}if(l==="<"){t++}else if(l==="/"&&e<r.length-1&&r[e+1]===">"){t--}}if(n<r.length){o.push(r.substring(n))}}const i=[];const s=[];while(o.length>0){const r=ge(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),ge(r.substr(8)))}else if(r.indexOf("find ")===0){e=u(f(t),ge(r.substr(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,ge(r.substr(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=me(t,ge(r.substr(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=m(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=f(m(t,!!n));i.push(...M(c.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=0;e<r.length;e++){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_PRECEDING){return o}}};var me=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=r.length-1;e>=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return p(e,t)[0]}else{return p(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return u(f(t)||document,e)}else{return e}}function xe(e,t,n,r){if(k(t)){return{target:ne().body,event:J(e),listener:t,options:n}}else{return{target:y(e),event:J(t),listener:n,options:r}}}function ye(t,n,r,o){Vn(function(){const e=xe(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function be(t,n,r){Vn(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const ve=ne().createElement("output");function we(e,t){const n=re(e,t);if(n){if(n==="this"){return[Se(e,t)]}else{const r=p(e,n);if(r.length===0){O('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ue(o(e,function(e){return te(ue(e),t)!=null}))}function Ee(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Ce(t){const n=Q.config.attributesToSettle;for(let e=0;e<n.length;e++){if(t===n[e]){return true}}return false}function Oe(t,n){se(t.attributes,function(e){if(!n.hasAttribute(e.name)&&Ce(e.name)){t.removeAttribute(e.name)}});se(n.attributes,function(e){if(Ce(e.name)){t.setAttribute(e.name,e.value)}})}function Re(t,e){const n=Un(e);for(let e=0;e<n.length;e++){const r=n[e];try{if(r.isInlineSwap(t)){return true}}catch(e){O(e)}}return t==="outerHTML"}function He(e,o,i,t){t=t||ne();let n="#"+ee(o,"id");let s="outerHTML";if(e==="true"){}else if(e.indexOf(":")>0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=p(t,n,false);if(r){se(r,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=f(n)}const r={shouldSwap:true,target:e,fragment:t};if(!he(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);_e(s,e,e,t,i);Te()}se(i.elts,function(e){he(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=u("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=u("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){se(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){if(e.moveBefore){let e=u("#--htmx-preserve-pantry--");if(e==null){ne().body.insertAdjacentHTML("afterend","<div id='--htmx-preserve-pantry--'></div>");e=u("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Le(l,e,c){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=f(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Ae(e){return function(){G(e,Q.config.addedClass);kt(ue(e));Ne(f(e));he(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=$(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function a(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n<e.length){t=(t<<5)-t+e.charCodeAt(n++)|0}return t}function Pe(t){let n=0;if(t.attributes){for(let e=0;e<t.attributes.length;e++){const r=t.attributes[e];if(r.value){n=Ie(r.name,n);n=Ie(r.value,n)}}}return n}function ke(t){const n=ie(t);if(n.onHandlers){for(let e=0;e<n.onHandlers.length;e++){const r=n.onHandlers[e];be(t,r.event,r.listener)}delete n.onHandlers}}function De(e){const t=ie(e);if(t.timeout){clearTimeout(t.timeout)}if(t.listenerInfos){se(t.listenerInfos,function(e){if(e.on){be(e.on,e.trigger,e.listener)}})}ke(e);se(Object.keys(t),function(e){if(e!=="firstInitCompleted")delete t[e]})}function b(e){he(e,"htmx:beforeCleanupElement");De(e);if(e.children){se(e.children,function(e){b(e)})}}function Me(t,e,n){if(t instanceof Element&&t.tagName==="BODY"){return Ve(t,e,n)}let r;const o=t.previousSibling;const i=c(t);if(!i){return}a(i,t,e,n);if(o==null){r=i.firstChild}else{r=o.nextSibling}n.elts=n.elts.filter(function(e){return e!==t});while(r&&r!==t){if(r instanceof Element){n.elts.push(r)}r=r.nextSibling}b(t);if(t instanceof Element){t.remove()}else{t.parentNode.removeChild(t)}}function Xe(e,t,n){return a(e,e.firstChild,t,n)}function Fe(e,t,n){return a(c(e),e,t,n)}function Be(e,t,n){return a(e,null,t,n)}function Ue(e,t,n){return a(c(e),e.nextSibling,t,n)}function je(e){b(e);const t=c(e);if(t){return t.removeChild(e)}}function Ve(e,t,n){const r=e.firstChild;a(e,r,t,n);if(r){while(r.nextSibling){b(r.nextSibling);e.removeChild(r.nextSibling)}b(r);e.removeChild(r)}}function _e(t,e,n,r,o){switch(t){case"none":return;case"outerHTML":Me(n,r,o);return;case"afterbegin":Xe(n,r,o);return;case"beforebegin":Fe(n,r,o);return;case"beforeend":Be(n,r,o);return;case"afterend":Ue(n,r,o);return;case"delete":je(n);return;default:var i=Un(e);for(let e=0;e<i.length;e++){const s=i[e];try{const l=s.handleSwap(t,n,r,o);if(l){if(Array.isArray(l)){for(let e=0;e<l.length;e++){const c=l[e];if(c.nodeType!==Node.TEXT_NODE&&c.nodeType!==Node.COMMENT_NODE){o.tasks.push(Ae(c))}}}return}}catch(e){O(e)}}if(t==="innerHTML"){Ve(n,r,o)}else{_e(Q.config.defaultSwapStyle,e,n,r,o)}}}function ze(e,n,r){var t=x(e,"[hx-swap-oob], [data-hx-swap-oob]");se(t,function(e){if(Q.config.allowNestedOobSwaps||e.parentElement===null){const t=te(e,"hx-swap-oob");if(t!=null){He(t,e,n,r)}}else{e.removeAttribute("hx-swap-oob");e.removeAttribute("data-hx-swap-oob")}});return t.length>0}function $e(e,t,r,o){if(!o){o={}}e=y(e);const i=o.contextElement?m(o.contextElement,false):ne();const n=document.activeElement;let s={};try{s={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const l=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=P(t);l.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t<u.length;t++){const a=u[t].split(":",2);let e=a[0].trim();if(e.indexOf("#")===0){e=e.substring(1)}const f=a[1]||"true";const h=n.querySelector("#"+e);if(h){He(f,h,l,i)}}}ze(n,l,i);se(x(n,"template"),function(e){if(e.content&&ze(e.content,l,i)){e.remove()}});if(o.select){const d=ne().createDocumentFragment();se(n.querySelectorAll(o.select),function(e){d.appendChild(e)});n=d}qe(n);_e(r.swapStyle,o.contextElement,e,n,l);Te()}if(s.elt&&!le(s.elt)&&ee(s.elt,"id")){const g=document.getElementById(ee(s.elt,"id"));const p={preventScroll:r.focusScroll!==undefined?!r.focusScroll:!Q.config.defaultFocusScroll};if(g){if(s.start&&g.setSelectionRange){try{g.setSelectionRange(s.start,s.end)}catch(e){}}g.focus(p)}}e.classList.remove(Q.config.swappingClass);se(l.elts,function(e){if(e.classList){e.classList.add(Q.config.settlingClass)}he(e,"htmx:afterSwap",o.eventInfo)});if(o.afterSwapCallback){o.afterSwapCallback()}if(!r.ignoreTitle){kn(l.title)}const c=function(){se(l.tasks,function(e){e.call()});se(l.elts,function(e){if(e.classList){e.classList.remove(Q.config.settlingClass)}he(e,"htmx:afterSettle",o.eventInfo)});if(o.anchor){const e=ue(y("#"+o.anchor));if(e){e.scrollIntoView({block:"start",behavior:"auto"})}}yn(l.elts,r);if(o.afterSettleCallback){o.afterSettleCallback()}};if(r.settleDelay>0){E().setTimeout(c,r.settleDelay)}else{c()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(D(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}he(n,i,e)}}}else{const s=r.split(",");for(let e=0;e<s.length;e++){he(n,s[e].trim(),[])}}}const Ke=/\s/;const v=/[\s,]/;const Ge=/[_$a-zA-Z]/;const We=/[_$a-zA-Z0-9]/;const Ze=['"',"'","/"];const w=/[^\s]/;const Ye=/[{(]/;const Qe=/[})]/;function et(e){const t=[];let n=0;while(n<e.length){if(Ge.exec(e.charAt(n))){var r=n;while(We.exec(e.charAt(n+1))){n++}t.push(e.substring(r,n+1))}else if(Ze.indexOf(e.charAt(n))!==-1){const o=e.charAt(n);var r=n;n++;while(n<e.length&&e.charAt(n)!==o){if(e.charAt(n)==="\\"){n++}n++}t.push(e.substring(r,n+1))}else{const i=e.charAt(n);t.push(i)}n++}return t}function tt(e,t,n){return Ge.exec(e.charAt(0))&&e!=="true"&&e!=="false"&&e!=="this"&&e!==n&&t!=="."}function nt(r,o,i){if(o[0]==="["){o.shift();let e=1;let t=" return (function("+i+"){ return (";let n=null;while(o.length>0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function C(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=C(e,Qe).trim();e.shift()}else{t=C(e,v)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{C(o,w);const l=o.length;const c=C(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};C(o,w);u.pollInterval=d(C(o,/[,\[\s]/));C(o,w);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=nt(e,o,"event");if(i){a.eventFilter=i}C(o,w);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=d(C(o,v))}else if(f==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=C(o,v);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=rt(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=d(C(o,v))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=C(o,v)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=rt(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=C(o,v)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!gt(n,e,Mt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ft(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=ne().location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){pt(t,function(e,t){const n=ue(e);if(at(n)){b(n);return}de(r,o,n,t)},n,e,true)})}}function ht(e,t){const n=ue(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(h(n,'input[type="submit"], button')&&(h(n,"[form]")||g(n,"form")!==null)){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function dt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function pt(l,c,e,u,a){const f=ie(l);let t;if(u.from){t=p(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}se(t,function(i){const s=function(e){if(!le(l)){i.removeEventListener(u.trigger,s);return}if(dt(l,e)){return}if(a||ht(e,l)){e.preventDefault()}if(gt(u,l,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(ue(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=event.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){he(l,"htmx:trigger");c(l,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){he(l,"htmx:trigger");c(l,e)},u.delay)}else{he(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let xt=null;function yt(){if(!xt){xt=function(){mt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(mt){mt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&X(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){he(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){he(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;he(e,"htmx:trigger");t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;se(r,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(g(n,Q.config.disableSelector)){b(n);return}de(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){yt();pt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e<t.length;e++){const n=t[e];if(n.isIntersecting){he(r,"intersect");break}}},o);i.observe(ue(r));pt(ue(r),n,t,e)}else if(!t.firstInitCompleted&&e.trigger==="load"){if(!gt(e,r,Mt("load",{elt:r}))){vt(ue(r),n,t,e.delay)}}else if(e.pollInterval>0){t.polling=true;ct(ue(r),n,e)}else{pt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e<n.length;e++){const r=n[e].name;if(l(r,"hx-on:")||l(r,"data-hx-on:")||l(r,"hx-on-")||l(r,"data-hx-on-")){return true}}return false}const Ct=(new XPathEvaluator).createExpression('.//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") or'+' starts-with(name(), "hx-on-") or starts-with(name(), "data-hx-on-") ]]');function Ot(e,t){if(Et(e)){t.push(ue(e))}const n=Ct.evaluate(e);let r=null;while(r=n.iterateNext())t.push(ue(r))}function Rt(e){const t=[];if(e instanceof DocumentFragment){for(const n of e.childNodes){Ot(n,t)}}else{Ot(e,t)}return t}function Ht(e){if(e.querySelectorAll){const n=", [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost]";const r=[];for(const i in Mn){const s=Mn[i];if(s.getSelectors){var t=s.getSelectors();if(t){r.push(t)}}}const o=e.querySelectorAll(H+n+", form, [type='submit'],"+" [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger]"+r.flat().map(e=>", "+e).join(""));return o}else{return[]}}function Tt(e){const t=g(ue(e.target),"button, input[type='submit']");const n=Lt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Lt(e){const t=g(ue(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function Nt(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function It(t){ke(t);for(let e=0;e<t.attributes.length;e++){const n=t.attributes[e].name;const r=t.attributes[e].value;if(l(n,"hx-on")||l(n,"data-hx-on")){const o=n.indexOf("-on")+3;const i=n.slice(o,o+1);if(i==="-"||i===":"){let e=n.slice(o+1);if(l(e,":")){e="htmx"+e}else if(l(e,"-")){e="htmx:"+e.slice(1)}else if(l(e,"htmx-")){e="htmx:"+e.slice(5)}Nt(t,e,r)}}}}function Pt(t){if(g(t,Q.config.disableSelector)){b(t);return}const n=ie(t);const e=Pe(t);if(n.initHash!==e){De(t);n.initHash=e;he(t,"htmx:beforeProcessNode");const r=st(t);const o=wt(t,n,r);if(!o){if(re(t,"hx-boost")==="true"){ft(t,n,r)}else if(s(t,"hx-trigger")){r.forEach(function(e){St(t,e,n,function(){})})}}if(t.tagName==="FORM"||ee(t,"type")==="submit"&&s(t,"form")){At(t)}n.firstInitCompleted=true;he(t,"htmx:afterProcessNode")}}function kt(e){e=y(e);if(g(e,Q.config.disableSelector)){b(e);return}Pt(e);se(Ht(e),function(e){Pt(e)});se(Rt(e),It)}function Dt(e){return e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase()}function Mt(e,t){let n;if(window.CustomEvent&&typeof window.CustomEvent==="function"){n=new CustomEvent(e,{bubbles:true,cancelable:true,composed:true,detail:t})}else{n=ne().createEvent("CustomEvent");n.initCustomEvent(e,true,true,t)}return n}function fe(e,t,n){he(e,t,ce({error:t},n))}function Xt(e){return e==="htmx:afterProcessNode"}function Ft(e,t){se(Un(e),function(e){try{t(e)}catch(e){O(e)}})}function O(e){if(console.error){console.error(e)}else if(console.log){console.log("ERROR: ",e)}}function he(e,t,n){e=y(e);if(n==null){n={}}n.elt=e;const r=Mt(t,n);if(Q.logger&&!Xt(t)){Q.logger(e,t,n)}if(n.error){O(n.error);he(e,"htmx:error",{errorInfo:n})}let o=e.dispatchEvent(r);const i=Dt(t);if(o&&i!==t){const s=Mt(i,r.detail);o=o&&e.dispatchEvent(s)}Ft(ue(e),function(e){o=o&&(e.onEvent(t,r)!==false&&!r.defaultPrevented)});return o}let Bt=location.pathname+location.search;function Ut(){const e=ne().querySelector("[hx-history-elt],[data-hx-history-elt]");return e||ne().body}function jt(t,e){if(!B()){return}const n=_t(e);const r=ne().title;const o=window.scrollY;if(Q.config.historyCacheSize<=0){localStorage.removeItem("htmx-history-cache");return}t=U(t);const i=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e<i.length;e++){if(i[e].url===t){i.splice(e,1);break}}const s={url:t,content:n,title:r,scroll:o};he(ne().body,"htmx:historyItemCreated",{item:s,cache:i});i.push(s);while(i.length>Q.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Vt(t){if(!B()){return null}t=U(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e<n.length;e++){if(n[e].url===t){return n[e]}}return null}function _t(e){const t=Q.config.requestClass;const n=e.cloneNode(true);se(x(n,"."+t),function(e){G(e,t)});se(x(n,"[data-disabled-by-htmx]"),function(e){e.removeAttribute("disabled")});return n.innerHTML}function zt(){const e=Ut();const t=Bt||location.pathname+location.search;let n;try{n=ne().querySelector('[hx-history="false" i],[data-hx-history="false" i]')}catch(e){n=ne().querySelector('[hx-history="false"],[data-hx-history="false"]')}if(!n){he(ne().body,"htmx:beforeHistorySave",{path:t,historyElt:e});jt(t,e)}if(Q.config.historyEnabled)history.replaceState({htmx:true},ne().title,window.location.href)}function $t(e){if(Q.config.getCacheBusterParam){e=e.replace(/org\.htmx\.cache-buster=[^&]*&?/,"");if(Y(e,"&")||Y(e,"?")){e=e.slice(0,-1)}}if(Q.config.historyEnabled){history.pushState({htmx:true},"",e)}Bt=e}function Jt(e){if(Q.config.historyEnabled)history.replaceState({htmx:true},"",e);Bt=e}function Kt(e){se(e,function(e){e.call(undefined)})}function Gt(o){const e=new XMLHttpRequest;const i={path:o,xhr:e};he(ne().body,"htmx:historyCacheMiss",i);e.open("GET",o,true);e.setRequestHeader("HX-Request","true");e.setRequestHeader("HX-History-Restore-Request","true");e.setRequestHeader("HX-Current-URL",ne().location.href);e.onload=function(){if(this.status>=200&&this.status<400){he(ne().body,"htmx:historyCacheMissLoad",i);const e=P(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=Ut();const r=xn(n);kn(e.title);qe(e);Ve(n,t,r);Te();Kt(r.tasks);Bt=o;he(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=Vt(e);if(t){const n=P(t.content);const r=Ut();const o=xn(r);kn(t.title);qe(n);Ve(r,n,o);Te();Kt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;he(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Gt(e)}}}function Zt(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Yt(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function Qt(e,t){se(e.concat(t),function(e){const t=ie(e);t.requestCount=(t.requestCount||1)-1});se(e,function(e){const t=ie(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function en(t,n){for(let e=0;e<t.length;e++){const r=t[e];if(r.isSameNode(n)){return true}}return false}function tn(e){const t=e;if(t.name===""||t.name==null||t.disabled||g(t,"fieldset[disabled]")){return false}if(t.type==="button"||t.type==="submit"||t.tagName==="image"||t.tagName==="reset"||t.tagName==="file"){return false}if(t.type==="checkbox"||t.type==="radio"){return t.checked}return true}function nn(t,e,n){if(t!=null&&e!=null){if(Array.isArray(e)){e.forEach(function(e){n.append(t,e)})}else{n.append(t,e)}}}function rn(t,n,r){if(t!=null&&n!=null){let e=r.getAll(t);if(Array.isArray(n)){e=e.filter(e=>n.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function on(t,n,r,o,i){if(o==null||en(t,o)){return}else{t.push(o)}if(tn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=M(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=M(o.files)}nn(s,e,n);if(i){sn(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){rn(e.name,e.value,n)}else{t.push(e)}if(i){sn(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}nn(t,e,n)})}}function sn(e,t){const n=e;if(n.willValidate){he(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});he(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function ln(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){on(n,o,i,g(e,"form"),l)}on(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=ee(u,"name");nn(a,u.value,o)}const c=we(e,"hx-include");se(c,function(e){on(n,r,i,ue(e),l);if(!h(e,"form")){se(f(e).querySelectorAll(ot),function(e){on(n,r,i,e,l)})}});ln(r,o);return{errors:i,formData:r,values:An(r)}}function un(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=qn(e);let n="";e.forEach(function(e,t){n=un(n,t,e)});return n}function fn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};bn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function hn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function dn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function gn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!dn(e)){r.show="top"}if(n){const s=F(n);if(s.length>0){for(let e=0;e<s.length;e++){const l=s[e];if(l.indexOf("swap:")===0){r.swapDelay=d(l.slice(5))}else if(l.indexOf("settle:")===0){r.settleDelay=d(l.slice(7))}else if(l.indexOf("transition:")===0){r.transition=l.slice(11)==="true"}else if(l.indexOf("ignoreTitle:")===0){r.ignoreTitle=l.slice(12)==="true"}else if(l.indexOf("scroll:")===0){const c=l.slice(7);var o=c.split(":");const u=o.pop();var i=o.length>0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.slice(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{O("Unknown modifier in hx-swap: "+l)}}}}return r}function pn(e){return re(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function mn(t,n,r){let o=null;Ft(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(pn(n)){return ln(new FormData,qn(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function yn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function bn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return bn(ue(c(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function wn(e,t){return bn(e,"hx-vars",true,t)}function Sn(e,t){return bn(e,"hx-vals",false,t)}function En(e){return ce(wn(e),Sn(e))}function Cn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function R(e,t){return t.test(e.getAllResponseHeaders())}function Rn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return de(t,n,null,null,{targetOverride:y(r)||ve,returnPromise:true})}else{let e=y(r.target);if(r.target&&!e||r.source&&!e&&!y(r.source)){e=ve}return de(t,n,y(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return de(t,n,null,null,{returnPromise:true})}}function Hn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Tn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return he(e,"htmx:validateUrl",ce({url:o,sameHost:r},n))}function qn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Ln(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}else{return e[t]}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Ln(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function de(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Dn;const X=i.select||null;if(!le(r)){oe(s);return e}const c=i.targetOverride||ue(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let u=ie(r);const a=u.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const A=ee(a,"formmethod");if(A!=null){if(A.toLowerCase()!=="dialog"){t=A}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return de(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(he(r,"htmx:confirm",G)===false){oe(s);return e}}let h=r;let d=re(r,"hx-sync");let g=null;let F=false;if(d){const N=d.split(":");const I=N[0].trim();if(I==="this"){h=Se(r,"hx-sync")}else{h=ue(ae(r,I))}d=(N[1]||"drop").trim();u=ie(h);if(d==="drop"&&u.xhr&&u.abortable!==true){oe(s);return e}else if(d==="abort"){if(u.xhr){oe(s);return e}else{F=true}}else if(d==="replace"){he(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");g=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){he(h,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(g==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="all"){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){de(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;u.xhr=p;u.abortable=F;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var x=prompt(B);if(x===null||!he(r,"htmx:prompt",{prompt:x,target:c})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let y=fn(r,c,x);if(t!=="get"&&!pn(r)){y["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){y=ce(y,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){ln(j,qn(i.values))}const V=qn(En(r));const v=ln(j,V);let w=hn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=bn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:w,parameters:An(w),unfilteredFormData:v,unfilteredParameters:An(v),headers:y,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!he(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;y=C.headers;w=qn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){he(r,"htmx:validation:halted",C);oe(s);m();return e}const z=n.split("#");const $=z[0];const O=z[1];let R=n;if(E){R=$;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=an(w);if(O){R+="#"+O}}}if(!Tn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in y){if(y.hasOwnProperty(k)){const Y=y[k];Cn(p,k,Y)}}}const H={xhr:p,target:c,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Hn(r);H.pathInfo.responsePath=On(p);M(r,H);if(H.keepIndicators!==true){Qt(T,q)}he(r,"htmx:afterRequest",H);he(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){he(e,"htmx:afterRequest",H);he(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ce({error:e},H));throw e}};p.onerror=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!he(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Zt(r);var q=Yt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){he(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});he(r,"htmx:beforeSend",H);const J=E?null:mn(p,r,w);p.send(J);return e}function Nn(e,t){const n=t.xhr;let r=null;let o=null;if(R(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(R(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(R(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const c=re(e,"hx-replace-url");const u=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function In(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Pn(e){for(var t=0;t<Q.config.responseHandling.length;t++){var n=Q.config.responseHandling[t];if(In(n,e.status)){return n}}return{swap:false}}function kn(e){if(e){const t=u("title");if(t){t.innerHTML=e}else{window.document.title=e}}}function Dn(o,i){const s=i.xhr;let l=i.target;const e=i.etc;const c=i.select;if(!he(o,"htmx:beforeOnLoad",i))return;if(R(s,/HX-Trigger:/i)){Je(s,"HX-Trigger",o)}if(R(s,/HX-Location:/i)){zt();let e=s.getResponseHeader("HX-Location");var t;if(e.indexOf("{")===0){t=S(e);e=t.path;delete t.path}Rn("get",e,t).then(function(){$t(e)});return}const n=R(s,/HX-Refresh:/i)&&s.getResponseHeader("HX-Refresh")==="true";if(R(s,/HX-Redirect:/i)){i.keepIndicators=true;location.href=s.getResponseHeader("HX-Redirect");n&&location.reload();return}if(n){i.keepIndicators=true;location.reload();return}if(R(s,/HX-Retarget:/i)){if(s.getResponseHeader("HX-Retarget")==="this"){i.target=o}else{i.target=ue(ae(o,s.getResponseHeader("HX-Retarget")))}}const u=Nn(o,i);const r=Pn(s);const a=r.swap;let f=!!r.error;let h=Q.config.ignoreTitle||r.ignoreTitle;let d=r.select;if(r.target){i.target=ue(ae(o,r.target))}var g=e.swapOverride;if(g==null&&r.swapOverride){g=r.swapOverride}if(R(s,/HX-Retarget:/i)){if(s.getResponseHeader("HX-Retarget")==="this"){i.target=o}else{i.target=ue(ae(o,s.getResponseHeader("HX-Retarget")))}}if(R(s,/HX-Reswap:/i)){g=s.getResponseHeader("HX-Reswap")}var p=s.response;var m=ce({shouldSwap:a,serverResponse:p,isError:f,ignoreTitle:h,selectOverride:d,swapOverride:g},i);if(r.event&&!he(l,r.event,m))return;if(!he(l,"htmx:beforeSwap",m))return;l=m.target;p=m.serverResponse;f=m.isError;h=m.ignoreTitle;d=m.selectOverride;g=m.swapOverride;i.target=l;i.failed=f;i.successful=!f;if(m.shouldSwap){if(s.status===286){lt(o)}Ft(o,function(e){p=e.transformResponse(p,s,o)});if(u.type){zt()}var x=gn(o,g);if(!x.hasOwnProperty("ignoreTitle")){x.ignoreTitle=h}l.classList.add(Q.config.swappingClass);let n=null;let r=null;if(c){d=c}if(R(s,/HX-Reselect:/i)){d=s.getResponseHeader("HX-Reselect")}const y=re(o,"hx-select-oob");const b=re(o,"hx-select");let e=function(){try{if(u.type){he(ne().body,"htmx:beforeHistoryUpdate",ce({history:u},i));if(u.type==="push"){$t(u.path);he(ne().body,"htmx:pushedIntoHistory",{path:u.path})}else{Jt(u.path);he(ne().body,"htmx:replacedInHistory",{path:u.path})}}$e(l,p,x,{select:d||b,selectOOB:y,eventInfo:i,anchor:i.pathInfo.anchor,contextElement:o,afterSwapCallback:function(){if(R(s,/HX-Trigger-After-Swap:/i)){let e=o;if(!le(o)){e=ne().body}Je(s,"HX-Trigger-After-Swap",e)}},afterSettleCallback:function(){if(R(s,/HX-Trigger-After-Settle:/i)){let e=o;if(!le(o)){e=ne().body}Je(s,"HX-Trigger-After-Settle",e)}oe(n)}})}catch(e){fe(o,"htmx:swapError",i);oe(r);throw e}};let t=Q.config.globalViewTransitions;if(x.hasOwnProperty("transition")){t=x.transition}if(t&&he(o,"htmx:beforeTransition",i)&&typeof Promise!=="undefined"&&document.startViewTransition){const v=new Promise(function(e,t){n=e;r=t});const w=e;e=function(){document.startViewTransition(function(){w();return v})}}if(x.swapDelay>0){E().setTimeout(e,x.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ce({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Mn={};function Xn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Fn(e,t){if(t.init){t.init(n)}Mn[e]=ce(Xn(),t)}function Bn(e){delete Mn[e]}function Un(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Mn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Un(ue(c(e)),n,r)}var jn=false;ne().addEventListener("DOMContentLoaded",function(){jn=true});function Vn(e){if(jn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function _n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend","<style"+e+">      ."+Q.config.indicatorClass+"{opacity:0}      ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;}      ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;}      </style>")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function $n(){const e=zn();if(e){Q.config=ce(Q.config,e)}}Vn(function(){$n();_n();let e=ne().body;kt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){he(e,"htmx:restored",{document:ne(),triggerEvent:he})})}else{if(n){n(e)}}};E().setTimeout(function(){he(e,"htmx:load",{});e=null},0)});return Q}();
\ No newline at end of file
diff --git a/internal/web/static/style.css b/internal/web/static/style.css
new file mode 100644
index 0000000..dff683a
--- /dev/null
+++ b/internal/web/static/style.css
@@ -0,0 +1,218 @@
+/* simplegit — josie-c.com, dark. Same shape as the main site's stylesheet. */
+
+/* Variables: */
+:root {
+  --font: 'Courier New', Courier, monospace;
+  --bgcolor: #16172b;   /* page — the main site's #CCCCFF, inverted dark */
+  --color1: #1e1f3a;    /* nav bar + boxes — the main site's #FFFFCC slot */
+  --color2: #2f3160;    /* hover/active — the main site's #CCFFE6 slot */
+  --text: #dcdcf5;
+  --muted: #8f92c4;
+  --link: #a5aef0;      /* periwinkle, nod to #CCCCFF */
+  --success: #7bc275;
+  --danger: #e06c75;
+  --warning: #e0af68;
+}
+
+* { box-sizing: border-box; }
+html { color-scheme: dark; }
+
+/* --- top nav bar styling ------ */
+/* 1fr auto 1fr puts the home link exactly on the page midline whatever the
+   username's length; the side cells carry one dash each toward the center. */
+.topnav {
+  display: grid;
+  grid-template-columns: 1fr auto 1fr;
+  background-color: var(--color1);
+}
+.topnav .nav-side { display: flex; }
+.topnav .nav-left { justify-content: flex-end; }
+.topnav .nav-right { justify-content: flex-start; }
+.topnav a {
+  display: block;
+  color: var(--text);
+  padding: 14px 10px;
+  text-decoration: none;
+}
+.topnav a:hover { background-color: var(--color2); }
+/* --- end top nav bar styling --- */
+
+/* --- main text styling ------ */
+body {
+  margin: 0;
+  background-color: var(--bgcolor);
+  color: var(--text);
+  font-family: var(--font);
+  font-size: 1.3rem;
+  text-align: center;
+}
+
+a { color: var(--link); }
+
+main { padding: 0 12px 48px; }
+
+.muted { color: var(--muted); }
+.error { color: var(--danger); }
+.ok { color: var(--success); }
+.sep { color: var(--muted); }
+
+form { text-align: left; }
+/* --- end main text styling --- */
+
+/* --- git ui: flat boxes, no borders or rounding ------ */
+pre {
+  background: var(--color1);
+  padding: 12px;
+  overflow-x: auto;
+  text-align: left;
+  font-size: 1rem;
+}
+
+article { overflow-x: auto; text-align: left; }
+
+.diff { background: var(--color1); padding: 12px; }
+.diff pre, .diff .chroma { background: none; padding: 0; margin: 0; }
+
+table { border-collapse: collapse; margin: 0 auto; text-align: left; }
+td, th { border: 1px solid var(--color2); padding: 6px 10px; }
+
+button {
+  font: inherit;
+  background: var(--color2);
+  color: var(--text);
+  border: 0;
+  padding: 6px 14px;
+  cursor: pointer;
+}
+button:hover { background: var(--link); color: var(--bgcolor); }
+button.linklike {
+  background: none;
+  border: 0;
+  padding: 0;
+  color: var(--link);
+  text-decoration: underline;
+}
+button.linklike:hover { color: var(--text); background: none; }
+
+input, select, textarea {
+  font: inherit;
+  background: var(--color1);
+  color: var(--text);
+  border: 1px solid var(--color2);
+  padding: 6px 8px;
+}
+textarea { width: 100%; resize: vertical; }
+
+.panel, .card {
+  background: var(--color1);
+  padding: 16px;
+  margin: 0 auto 16px;
+  text-align: left;
+}
+.panel { max-width: 560px; }
+.panel input:not([type=radio]):not([type=checkbox]), .panel select, .panel textarea { width: 100%; }
+
+.badge { font-size: .8em; color: var(--muted); }
+.badge.open { color: var(--success); }
+.badge.closed { color: var(--danger); }
+.badge.pending { color: var(--warning); }
+
+.repo-list, .issue-list { list-style: none; padding: 0; }
+.repo-list li, .issue-row { padding: 8px 0; }
+
+.list-toolbar { display: flex; justify-content: space-between; align-items: baseline; margin: 0 0 16px; }
+.list-toolbar p { margin: 0; }
+
+.issue-state { display: flex; gap: 12px; align-items: baseline; flex-wrap: wrap; justify-content: center; margin: 0 0 8px; }
+.issue-state form, .comment-actions form { display: inline; }
+.issue-filters a.active { color: var(--text); }
+.comment {
+  background: var(--color1);
+  padding: 12px 16px;
+  margin: 0 0 12px;
+  text-align: left;
+}
+.comment.pending { outline: 1px solid var(--warning); }
+.comment-actions { display: flex; gap: 12px; }
+.guest-fields { border: 0; background: var(--color1); padding: 8px 16px 12px; }
+.guest-fields legend { color: var(--muted); padding: 0 6px; }
+.hp { position: absolute; left: -9999px; top: auto; width: 1px; height: 1px; overflow: hidden; }
+
+.profile-actions { display: flex; flex-direction: column; align-items: center; gap: 4px; margin: 0 0 16px; }
+
+.token { background: var(--bgcolor); padding: 2px 6px; }
+/* --- end git ui boxes --- */
+
+/* --- repo pages ------ */
+.repo-tabs {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: center;
+  background: var(--color1);
+  margin: 0 0 24px;
+}
+.repo-tabs a {
+  display: block;
+  color: var(--text);
+  padding: 10px 12px;
+  text-decoration: none;
+}
+.repo-tabs a:hover, .repo-tabs a.active { background: var(--color2); }
+
+.clone-row { display: flex; justify-content: center; gap: 8px; margin: 0 0 16px; }
+.clone-row input { width: 42ch; max-width: 60vw; font-size: 1rem; text-align: center; }
+
+.repo-layout { display: grid; gap: 24px; align-items: start; text-align: left; }
+@media (min-width: 600px) { .repo-layout { grid-template-columns: 280px minmax(0, 1fr); } }
+
+.file-tree-pane { background: var(--color1); padding: 12px 16px; }
+.file-tree { list-style: none; padding: 0; margin: 0; font-size: 1rem; }
+.file-tree .file-tree { padding-left: 1.2em; }
+.file-tree li { padding: 1px 0; }
+.file-tree a { text-decoration: none; }
+
+.repo-summary { display: flex; gap: 8px; margin: 0 0 16px; }
+.repo-summary > * {
+  flex: 1;
+  min-width: 0;
+  background: var(--color1);
+  text-align: center;
+}
+@media (max-width: 600px) {
+  .repo-summary { flex-wrap: wrap; }
+  .repo-summary > * { flex: 1 1 40%; }
+}
+.repo-summary > a {
+  color: var(--text);
+  text-decoration: none;
+  padding: 8px 12px;
+}
+.repo-summary > a:hover { background: var(--color2); }
+
+.branch-picker { position: relative; }
+.branch-picker summary {
+  cursor: pointer;
+  display: block;
+  padding: 8px 12px;
+}
+.branch-picker summary::after { content: " ▾"; color: var(--muted); }
+.branch-picker ul {
+  position: absolute;
+  left: 0;
+  right: 0;
+  top: 100%;
+  z-index: 1;
+  list-style: none;
+  margin: 0;
+  padding: 4px 0;
+  background: var(--color1);
+  text-align: left;
+}
+.branch-picker li { padding: 4px 12px; }
+.branch-picker li.current { color: var(--muted); }
+.branch-picker li a { display: block; text-decoration: none; }
+
+.file-list { list-style: none; padding: 0; background: var(--color1); }
+.file-list li { padding: 6px 12px; }
+.file-list form { display: inline; }
+/* --- end repo pages --- */
diff --git a/internal/web/templates/base.html b/internal/web/templates/base.html
new file mode 100644
index 0000000..0d54c93
--- /dev/null
+++ b/internal/web/templates/base.html
@@ -0,0 +1,38 @@
+{{define "base"}}<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{template "title" .}}</title>
+<link rel="stylesheet" href="/static/chroma.css">
+<link rel="stylesheet" href="/static/style.css">
+<script src="/static/htmx.min.js" defer></script>
+</head>
+<body>
+<header class="topnav">
+  <div class="nav-side nav-left">
+    {{if .Username}}<a href="/{{.Username}}">- {{.Username}}</a>{{else}}<a href="/login">- sign in</a>{{end}}
+  </div>
+  <div class="nav-center"><a href="/">- home -</a></div>
+  <div class="nav-side nav-right">
+    {{if .Username}}<a href="/new">new -</a>{{end}}
+  </div>
+</header>
+<main class="container">
+{{template "content" .}}
+</main>
+</body>
+</html>
+{{end}}
+
+{{define "repo_list"}}
+<ul class="repo-list">
+{{range .Repos}}
+  <li>
+    <a href="/{{.Owner}}/{{.Name}}">{{.Owner}}/{{.Name}}</a>
+    <span class="badge">{{.Visibility}}</span>
+    {{if .Description}}<div class="muted">{{.Description}}</div>{{end}}
+  </li>
+{{end}}
+</ul>
+{{end}}
diff --git a/internal/web/templates/blob.html b/internal/web/templates/blob.html
new file mode 100644
index 0000000..8af70df
--- /dev/null
+++ b/internal/web/templates/blob.html
@@ -0,0 +1,12 @@
+{{define "title"}}{{.Path}} · {{.Owner}}/{{.RepoName}} · simplegit{{end}}
+{{define "content"}}
+{{template "repo_nav" .}}
+<div class="repo-layout">
+  <nav class="file-tree-pane">{{template "filetree" .Tree}}</nav>
+  <div class="repo-content">
+    <p class="breadcrumb muted">{{.Ref}} / {{.Path}} · {{.Size}} bytes · <a href="{{.RawHref}}">raw</a></p>
+    {{if .Notice}}<p class="error">{{.Notice}}</p>{{end}}
+    {{if .CodeHTML}}<article>{{.CodeHTML}}</article>{{else if .RawText}}<pre>{{.RawText}}</pre>{{end}}
+  </div>
+</div>
+{{end}}
diff --git a/internal/web/templates/commit.html b/internal/web/templates/commit.html
new file mode 100644
index 0000000..0aac7da
--- /dev/null
+++ b/internal/web/templates/commit.html
@@ -0,0 +1,18 @@
+{{define "title"}}{{.Subject}} · {{.Owner}}/{{.RepoName}} · simplegit{{end}}
+{{define "content"}}
+{{template "repo_nav" .}}
+<h2>{{.Subject}}</h2>
+{{if .Body}}<pre>{{.Body}}</pre>{{end}}
+<p class="muted">
+  <code>{{.SHA}}</code><br>
+  {{.Author}} &lt;{{.Email}}&gt; · {{.Date}} ·
+  <a href="{{.TreeHref}}">browse files at this commit</a>
+</p>
+{{if .Parents}}
+<p class="muted">parents:
+  {{range .Parents}}<a href="{{.Href}}"><code>{{.SHA}}</code></a> {{end}}
+</p>
+{{end}}
+{{if .Notice}}<p class="error">{{.Notice}}</p>{{end}}
+<article>{{.DiffHTML}}</article>
+{{end}}
diff --git a/internal/web/templates/commits.html b/internal/web/templates/commits.html
new file mode 100644
index 0000000..e72653a
--- /dev/null
+++ b/internal/web/templates/commits.html
@@ -0,0 +1,15 @@
+{{define "title"}}commits · {{.Owner}}/{{.RepoName}} · simplegit{{end}}
+{{define "content"}}
+{{template "repo_nav" .}}
+<h2>commits <span class="muted">on {{.Ref}}</span></h2>
+<table>
+  {{range .Commits}}
+  <tr>
+    <td><a href="{{.Href}}">{{.Subject}}</a></td>
+    <td class="muted">{{.Author}}</td>
+    <td class="muted">{{.Date}}</td>
+    <td><code>{{.SHA}}</code></td>
+  </tr>
+  {{end}}
+</table>
+{{end}}
diff --git a/internal/web/templates/created.html b/internal/web/templates/created.html
new file mode 100644
index 0000000..b85c026
--- /dev/null
+++ b/internal/web/templates/created.html
@@ -0,0 +1,11 @@
+{{define "title"}}{{.RepoName}} · simplegit{{end}}
+{{define "content"}}
+<h1>{{.RepoName}} created</h1>
+<div class="card">
+<p>The repository is empty until your first push. To put an existing project in it:</p>
+<pre>cd existing_repo
+git remote add origin {{.PushURL}}
+git push -u origin --all
+git push origin --tags</pre>
+</div>
+{{end}}
diff --git a/internal/web/templates/home.html b/internal/web/templates/home.html
new file mode 100644
index 0000000..a97f4a0
--- /dev/null
+++ b/internal/web/templates/home.html
@@ -0,0 +1,9 @@
+{{define "title"}}simplegit{{end}}
+{{define "content"}}
+<h1>repositories</h1>
+{{if .Repos}}
+{{template "repo_list" .}}
+{{else}}
+<p class="muted">No repositories yet. {{if .Username}}<a href="/new">Create one</a>.{{else}}<a href="/login">Sign in</a> to create one.{{end}}</p>
+{{end}}
+{{end}}
diff --git a/internal/web/templates/issue.html b/internal/web/templates/issue.html
new file mode 100644
index 0000000..6605ded
--- /dev/null
+++ b/internal/web/templates/issue.html
@@ -0,0 +1,50 @@
+{{define "title"}}#{{.Number}} {{.Title}} · {{.Owner}}/{{.RepoName}} · simplegit{{end}}
+{{define "content"}}
+{{template "repo_nav" .}}
+<h2><span class="muted">#{{.Number}}</span> {{.Title}}</h2>
+{{template "state" .}}
+
+<article class="issue-body">{{.BodyHTML}}</article>
+<p class="issue-meta muted">opened by {{.Author}}{{if .AuthorIsOwner}} <span class="badge">owner</span>{{end}}{{if .IsOwner}}{{if .AuthorEmail}} &lt;{{.AuthorEmail}}&gt;{{end}}{{end}} · {{.Created}}</p>
+
+{{if .Submitted}}<p class="ok">your comment was submitted and is awaiting review.</p>{{end}}
+
+<h2>comments</h2>
+<section id="comments">
+{{range .Comments}}{{template "comment" .}}{{end}}
+</section>
+
+{{if .CanWrite}}
+<form id="comment-form" method="post" action="{{.Base}}/comments"
+      hx-post="{{.Base}}/comments"
+      hx-target="{{if .IsOwner}}#comments{{else}}#comment-status{{end}}"
+      hx-swap="{{if .IsOwner}}beforeend{{else}}innerHTML{{end}}"
+      hx-on::after-request="if(event.detail.successful) this.reset()">
+  <p><label for="comment-body">comment</label><br>
+     <textarea id="comment-body" name="body" rows="6" required></textarea></p>
+  {{if not .IsOwner}}{{template "guest_fields" "comment_"}}{{end}}
+  <p class="hp"><label>website <input name="website" tabindex="-1" autocomplete="off"></label></p>
+  <p><button type="submit">comment</button></p>
+</form>
+<div id="comment-status"></div>
+{{end}}
+{{end}}
+
+{{define "state"}}
+<div id="issue-state" class="issue-state">
+  <span class="badge {{.State}}">{{.State}}</span>
+  {{if .Pending}}<span class="badge pending">pending</span>{{end}}
+  {{if .IsOwner}}
+    {{if .Pending}}
+    <form method="post" action="{{.Base}}/approve"><button class="linklike" type="submit">approve</button></form>
+    {{end}}
+    {{if eq .State "open"}}
+    <form method="post" action="{{.Base}}/close" hx-post="{{.Base}}/close" hx-target="#issue-state" hx-swap="outerHTML"><button class="linklike" type="submit">close</button></form>
+    {{else}}
+    <form method="post" action="{{.Base}}/reopen" hx-post="{{.Base}}/reopen" hx-target="#issue-state" hx-swap="outerHTML"><button class="linklike" type="submit">reopen</button></form>
+    {{end}}
+    <form method="post" action="{{.Base}}/delete"><button class="linklike" type="submit">delete</button></form>
+  {{end}}
+</div>
+{{end}}
+
diff --git a/internal/web/templates/issue_new.html b/internal/web/templates/issue_new.html
new file mode 100644
index 0000000..5602342
--- /dev/null
+++ b/internal/web/templates/issue_new.html
@@ -0,0 +1,15 @@
+{{define "title"}}new issue · {{.Owner}}/{{.RepoName}} · simplegit{{end}}
+{{define "content"}}
+{{template "repo_nav" .}}
+<h2>new issue</h2>
+{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
+<form method="post" action="/{{.Owner}}/{{.RepoName}}/issues/new" class="panel">
+  <p><label for="title">title</label><br>
+     <input id="title" name="title" value="{{.Title}}" maxlength="300" required></p>
+  <p><label for="body">description</label><br>
+     <textarea id="body" name="body" rows="12">{{.Body}}</textarea></p>
+  {{if not .IsOwner}}{{template "guest_fields" "author_"}}{{end}}
+  <p class="hp"><label>website <input name="website" tabindex="-1" autocomplete="off"></label></p>
+  <p><button type="submit">file issue</button></p>
+</form>
+{{end}}
diff --git a/internal/web/templates/issue_submitted.html b/internal/web/templates/issue_submitted.html
new file mode 100644
index 0000000..0803cb5
--- /dev/null
+++ b/internal/web/templates/issue_submitted.html
@@ -0,0 +1,9 @@
+{{define "title"}}issue submitted · {{.Owner}}/{{.RepoName}} · simplegit{{end}}
+{{define "content"}}
+{{template "repo_nav" .}}
+<h2>thanks</h2>
+<div class="card">
+  <p>Your issue was submitted and is awaiting review.</p>
+  <p><a href="/{{.Owner}}/{{.RepoName}}/issues">← back to issues</a></p>
+</div>
+{{end}}
diff --git a/internal/web/templates/issues.html b/internal/web/templates/issues.html
new file mode 100644
index 0000000..e9cd686
--- /dev/null
+++ b/internal/web/templates/issues.html
@@ -0,0 +1,30 @@
+{{define "title"}}issues · {{.Owner}}/{{.RepoName}} · simplegit{{end}}
+{{define "content"}}
+{{template "repo_nav" .}}
+
+<div class="list-toolbar">
+  <p class="issue-filters muted">
+    <a href="?show=open" {{if eq .Show "open"}}class="active"{{end}}>open</a> ·
+    <a href="?show=closed" {{if eq .Show "closed"}}class="active"{{end}}>closed</a> ·
+    <a href="?show=all" {{if eq .Show "all"}}class="active"{{end}}>all</a>
+  </p>
+  {{if .CanWrite}}<p><a href="/{{.Owner}}/{{.RepoName}}/issues/new">new issue</a></p>{{end}}
+</div>
+
+{{if .IsOwner}}{{if gt .PendingCount 0}}<p><span class="badge pending">{{.PendingCount}} awaiting review</span></p>{{end}}{{end}}
+
+{{if .Issues}}
+<ul class="issue-list">
+{{range .Issues}}
+  <li class="issue-row">
+    <a class="issue-title" href="{{.Href}}">{{.Title}}</a>
+    <span class="badge {{.State}}">{{.State}}</span>
+    {{if .Pending}}<span class="badge pending">pending</span>{{end}}
+    <div class="muted">#{{.Number}} opened by {{.Author}}{{if .AuthorIsOwner}} (owner){{end}} · {{.Created}}</div>
+  </li>
+{{end}}
+</ul>
+{{else}}
+<p class="muted">No issues.</p>
+{{end}}
+{{end}}
diff --git a/internal/web/templates/login.html b/internal/web/templates/login.html
new file mode 100644
index 0000000..8d14470
--- /dev/null
+++ b/internal/web/templates/login.html
@@ -0,0 +1,16 @@
+{{define "title"}}sign in · simplegit{{end}}
+{{define "content"}}
+<h1>sign in</h1>
+{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
+<form method="post" action="/login" class="panel">
+  <p>
+    <label for="username">username</label><br>
+    <input id="username" name="username" value="{{.Username}}" autocomplete="username" required>
+  </p>
+  <p>
+    <label for="password">password</label><br>
+    <input id="password" name="password" type="password" autocomplete="current-password" required>
+  </p>
+  <p><button type="submit">sign in</button></p>
+</form>
+{{end}}
diff --git a/internal/web/templates/new.html b/internal/web/templates/new.html
new file mode 100644
index 0000000..5a12dc0
--- /dev/null
+++ b/internal/web/templates/new.html
@@ -0,0 +1,20 @@
+{{define "title"}}new repository · simplegit{{end}}
+{{define "content"}}
+<h1>new repository</h1>
+{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
+<form method="post" action="/new" class="panel">
+  <p>
+    <label for="name">name</label><br>
+    <input id="name" name="name" value="{{.Name}}" required>
+  </p>
+  <p>
+    <label for="description">description</label><br>
+    <input id="description" name="description" value="{{.Description}}">
+  </p>
+  <p>
+    <label><input type="radio" name="visibility" value="private" {{if ne .Visibility "public"}}checked{{end}}> private</label><br>
+    <label><input type="radio" name="visibility" value="public" {{if eq .Visibility "public"}}checked{{end}}> public</label>
+  </p>
+  <p><button type="submit">create repository</button></p>
+</form>
+{{end}}
diff --git a/internal/web/templates/profile.html b/internal/web/templates/profile.html
new file mode 100644
index 0000000..4d3998c
--- /dev/null
+++ b/internal/web/templates/profile.html
@@ -0,0 +1,18 @@
+{{define "title"}}{{.Profile}} · simplegit{{end}}
+{{define "content"}}
+<h1>{{.Profile}}</h1>
+
+{{if .IsSelf}}
+<div class="profile-actions">
+  <form method="post" action="/logout"><button class="linklike" type="submit">sign out</button></form>
+  <a href="/settings">settings</a>
+</div>
+{{end}}
+
+<h2>repositories</h2>
+{{if .Repos}}
+{{template "repo_list" .}}
+{{else}}
+<p class="muted">No repositories yet.{{if .IsSelf}} <a href="/new">Create one</a>.{{end}}</p>
+{{end}}
+{{end}}
diff --git a/internal/web/templates/pull.html b/internal/web/templates/pull.html
new file mode 100644
index 0000000..1ca848b
--- /dev/null
+++ b/internal/web/templates/pull.html
@@ -0,0 +1,56 @@
+{{define "title"}}#{{.Number}} {{.Title}} · {{.Owner}}/{{.RepoName}} · simplegit{{end}}
+{{define "content"}}
+{{template "repo_nav" .}}
+<h2><span class="muted">#{{.Number}}</span> {{.Title}}</h2>
+{{template "pstate" .}}
+<p class="issue-meta muted"><code>{{.Head}}</code> → <code>{{.Base}}</code> · opened by {{.Author}}{{if .AuthorIsOwner}} <span class="badge">owner</span>{{end}}{{if .IsOwner}}{{if .AuthorEmail}} &lt;{{.AuthorEmail}}&gt;{{end}}{{end}} · {{.Created}}{{if .MergeCommit}} · merged as <code>{{.MergeCommit}}</code>{{end}}</p>
+
+{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
+
+<article class="issue-body">{{.BodyHTML}}</article>
+
+<h2>changes</h2>
+{{if .DiffHTML}}<article class="diff">{{.DiffHTML}}</article>{{else}}<p class="muted">{{.DiffNotice}}</p>{{end}}
+
+{{if .Submitted}}<p class="ok">your comment was submitted and is awaiting review.</p>{{end}}
+
+<h2>comments</h2>
+<section id="pcomments">
+{{range .Comments}}{{template "comment" .}}{{end}}
+</section>
+
+{{if .CanWrite}}
+<form id="pcomment-form" method="post" action="{{.BasePath}}/comments"
+      hx-post="{{.BasePath}}/comments"
+      hx-target="{{if .IsOwner}}#pcomments{{else}}#pcomment-status{{end}}"
+      hx-swap="{{if .IsOwner}}beforeend{{else}}innerHTML{{end}}"
+      hx-on::after-request="if(event.detail.successful) this.reset()">
+  <p><label for="pcomment-body">comment</label><br>
+     <textarea id="pcomment-body" name="body" rows="6" required></textarea></p>
+  {{if not .IsOwner}}{{template "guest_fields" "pcomment_"}}{{end}}
+  <p class="hp"><label>website <input name="website" tabindex="-1" autocomplete="off"></label></p>
+  <p><button type="submit">comment</button></p>
+</form>
+<div id="pcomment-status"></div>
+{{end}}
+{{end}}
+
+{{define "pstate"}}
+<div id="pull-state" class="issue-state">
+  <span class="badge {{.State}}">{{.State}}</span>
+  {{if .Pending}}<span class="badge pending">pending</span>{{end}}
+  {{if .IsOwner}}
+    {{if .Pending}}
+    <form method="post" action="{{.BasePath}}/approve"><button class="linklike" type="submit">approve</button></form>
+    {{end}}
+    {{if eq .State "open"}}
+    <form method="post" action="{{.BasePath}}/merge"><button class="linklike" type="submit">merge</button></form>
+    <form method="post" action="{{.BasePath}}/close" hx-post="{{.BasePath}}/close" hx-target="#pull-state" hx-swap="outerHTML"><button class="linklike" type="submit">close</button></form>
+    {{else if eq .State "closed"}}
+    <form method="post" action="{{.BasePath}}/reopen" hx-post="{{.BasePath}}/reopen" hx-target="#pull-state" hx-swap="outerHTML"><button class="linklike" type="submit">reopen</button></form>
+    {{end}}
+    <form method="post" action="{{.BasePath}}/delete"><button class="linklike" type="submit">delete</button></form>
+  {{end}}
+</div>
+{{end}}
+
diff --git a/internal/web/templates/pull_new.html b/internal/web/templates/pull_new.html
new file mode 100644
index 0000000..ab811a3
--- /dev/null
+++ b/internal/web/templates/pull_new.html
@@ -0,0 +1,28 @@
+{{define "title"}}new pull request · {{.Owner}}/{{.RepoName}} · simplegit{{end}}
+{{define "content"}}
+{{template "repo_nav" .}}
+<h2>new pull request</h2>
+{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
+{{if lt (len .Branches) 2}}
+<p class="muted">Two branches are needed to open a pull request.</p>
+{{else}}
+<form method="post" action="/{{.Owner}}/{{.RepoName}}/pulls/new" class="panel">
+  <p><label for="head">head branch</label><br>
+     <select id="head" name="head">
+       {{range .Branches}}<option value="{{.}}" {{if eq . $.Head}}selected{{end}}>{{.}}</option>{{end}}
+     </select>
+     <span class="muted">→ merge into</span>
+     <label for="base">base branch</label>
+     <select id="base" name="base">
+       {{range .Branches}}<option value="{{.}}" {{if eq . $.Base}}selected{{end}}>{{.}}</option>{{end}}
+     </select></p>
+  <p><label for="title">title</label><br>
+     <input id="title" name="title" value="{{.Title}}" maxlength="300" required></p>
+  <p><label for="body">description</label><br>
+     <textarea id="body" name="body" rows="10">{{.Body}}</textarea></p>
+  {{if not .IsOwner}}{{template "guest_fields" "author_"}}{{end}}
+  <p class="hp"><label>website <input name="website" tabindex="-1" autocomplete="off"></label></p>
+  <p><button type="submit">open pull request</button></p>
+</form>
+{{end}}
+{{end}}
diff --git a/internal/web/templates/pull_submitted.html b/internal/web/templates/pull_submitted.html
new file mode 100644
index 0000000..0795874
--- /dev/null
+++ b/internal/web/templates/pull_submitted.html
@@ -0,0 +1,9 @@
+{{define "title"}}pull request submitted · {{.Owner}}/{{.RepoName}} · simplegit{{end}}
+{{define "content"}}
+{{template "repo_nav" .}}
+<h2>thanks</h2>
+<div class="card">
+  <p>Your pull request was submitted and is awaiting review.</p>
+  <p><a href="/{{.Owner}}/{{.RepoName}}/pulls">← back to pull requests</a></p>
+</div>
+{{end}}
diff --git a/internal/web/templates/pulls.html b/internal/web/templates/pulls.html
new file mode 100644
index 0000000..350a129
--- /dev/null
+++ b/internal/web/templates/pulls.html
@@ -0,0 +1,31 @@
+{{define "title"}}pulls · {{.Owner}}/{{.RepoName}} · simplegit{{end}}
+{{define "content"}}
+{{template "repo_nav" .}}
+
+<div class="list-toolbar">
+  <p class="issue-filters muted">
+    <a href="?show=open" {{if eq .Show "open"}}class="active"{{end}}>open</a> ·
+    <a href="?show=closed" {{if eq .Show "closed"}}class="active"{{end}}>closed</a> ·
+    <a href="?show=merged" {{if eq .Show "merged"}}class="active"{{end}}>merged</a> ·
+    <a href="?show=all" {{if eq .Show "all"}}class="active"{{end}}>all</a>
+  </p>
+  {{if .CanWrite}}<p><a href="/{{.Owner}}/{{.RepoName}}/pulls/new">new pull request</a></p>{{end}}
+</div>
+
+{{if .IsOwner}}{{if gt .PendingCount 0}}<p><span class="badge pending">{{.PendingCount}} awaiting review</span></p>{{end}}{{end}}
+
+{{if .Pulls}}
+<ul class="issue-list">
+{{range .Pulls}}
+  <li class="issue-row">
+    <a class="issue-title" href="{{.Href}}">{{.Title}}</a>
+    <span class="badge {{.State}}">{{.State}}</span>
+    {{if .Pending}}<span class="badge pending">pending</span>{{end}}
+    <div class="muted">#{{.Number}} <code>{{.Head}}</code> → <code>{{.Base}}</code> · opened by {{.Author}}{{if .AuthorIsOwner}} (owner){{end}} · {{.Created}}</div>
+  </li>
+{{end}}
+</ul>
+{{else}}
+<p class="muted">No pull requests.</p>
+{{end}}
+{{end}}
diff --git a/internal/web/templates/release.html b/internal/web/templates/release.html
new file mode 100644
index 0000000..81c06bf
--- /dev/null
+++ b/internal/web/templates/release.html
@@ -0,0 +1,33 @@
+{{define "title"}}{{.Title}} · {{.Owner}}/{{.RepoName}} · simplegit{{end}}
+{{define "content"}}
+{{template "repo_nav" .}}
+<h2>{{.Title}}</h2>
+<p class="issue-meta muted">
+  <span class="badge">{{.Tag}}</span>
+  {{if .Commit}}<code>{{.Commit}}</code> · {{end}}released {{.Created}}
+</p>
+
+<article class="issue-body">{{.Notes}}</article>
+
+<h2>assets</h2>
+{{if .Assets}}
+<ul class="file-list">
+{{range .Assets}}
+  <li>
+    <a href="{{.Download}}">{{.Name}}</a> <span class="muted">{{.Size}}</span>
+    {{if $.IsOwner}}
+    <form method="post" action="{{.Delete}}"><button class="linklike" type="submit">delete</button></form>
+    {{end}}
+  </li>
+{{end}}
+</ul>
+{{else}}
+<p class="muted">No assets.</p>
+{{end}}
+
+{{if .IsOwner}}
+<div class="issue-state">
+  <form method="post" action="{{.DeleteHref}}"><button class="linklike" type="submit">delete release</button></form>
+</div>
+{{end}}
+{{end}}
diff --git a/internal/web/templates/releases.html b/internal/web/templates/releases.html
new file mode 100644
index 0000000..40e6b67
--- /dev/null
+++ b/internal/web/templates/releases.html
@@ -0,0 +1,28 @@
+{{define "title"}}releases · {{.Owner}}/{{.RepoName}} · simplegit{{end}}
+{{define "content"}}
+{{template "repo_nav" .}}
+
+{{if .Releases}}
+<ul class="issue-list">
+{{range .Releases}}
+  <li class="issue-row">
+    <a class="issue-title" href="{{.Href}}">{{.Title}}</a>
+    <span class="badge">{{.Tag}}</span>
+    <div class="muted">released {{.Created}}</div>
+  </li>
+{{end}}
+</ul>
+{{else}}
+<p class="muted">No releases.</p>
+{{end}}
+
+{{if .Tags}}
+<h2>tags</h2>
+<p class="muted">Tags without a release.</p>
+<ul class="file-list">
+{{range .Tags}}
+  <li><code>{{.Name}}</code> <span class="muted">{{.Commit}}</span></li>
+{{end}}
+</ul>
+{{end}}
+{{end}}
diff --git a/internal/web/templates/repo.html b/internal/web/templates/repo.html
new file mode 100644
index 0000000..e36a379
--- /dev/null
+++ b/internal/web/templates/repo.html
@@ -0,0 +1,37 @@
+{{define "title"}}{{.Owner}}/{{.RepoName}} · simplegit{{end}}
+{{define "content"}}
+{{template "repo_nav" .}}
+
+{{if .Empty}}
+<div class="card">
+<p>The repository is empty so far. To put an existing project in it:</p>
+<pre>cd existing_repo
+git remote add origin {{.CloneURL}}
+git push -u origin --all
+git push origin --tags</pre>
+</div>
+{{else}}
+<div class="repo-summary">
+  <a href="/{{.Owner}}/{{.RepoName}}/commits/{{.Branch}}"><strong>{{.CommitCount}}</strong> {{if eq .CommitCount 1}}commit{{else}}commits{{end}}</a>
+  <details class="branch-picker">
+    <summary><strong>{{.BranchCount}}</strong> {{if eq .BranchCount 1}}branch{{else}}branches{{end}}</summary>
+    <ul>
+    {{range .Branches}}
+      {{if eq . $.Branch}}<li class="current">✓ {{.}}</li>
+      {{else if eq . $.DefaultBranch}}<li><a href="/{{$.Owner}}/{{$.RepoName}}">{{.}}</a></li>
+      {{else}}<li><a href="/{{$.Owner}}/{{$.RepoName}}/tree/{{.}}">{{.}}</a></li>{{end}}
+    {{end}}
+    </ul>
+  </details>
+  <a href="/{{.Owner}}/{{.RepoName}}/releases"><strong>{{.TagCount}}</strong> {{if eq .TagCount 1}}tag{{else}}tags{{end}}</a>
+  {{if .LicenseName}}<a href="{{.LicenseHref}}">{{.LicenseName}}</a>{{end}}
+</div>
+
+<div class="repo-layout">
+  <nav class="file-tree-pane">{{template "filetree" .Tree}}</nav>
+  <div class="repo-content">
+    {{if .Readme}}<article class="prose">{{.Readme}}</article>{{end}}
+  </div>
+</div>
+{{end}}
+{{end}}
diff --git a/internal/web/templates/repo_nav.html b/internal/web/templates/repo_nav.html
new file mode 100644
index 0000000..5396605
--- /dev/null
+++ b/internal/web/templates/repo_nav.html
@@ -0,0 +1,51 @@
+{{define "repo_nav"}}
+<div class="repo-header">
+  <h1 class="repo-title"><a href="/{{.Owner}}">{{.Owner}}</a> <span class="sep">/</span> <a href="/{{.Owner}}/{{.RepoName}}">{{.RepoName}}</a>{{if eq .Visibility "private"}} <span class="badge">private</span>{{end}}</h1>
+</div>
+<div class="clone-row">
+  <input id="clone-url" readonly value="{{cloneURL .Owner .RepoName}}" onclick="this.select()">
+  <button type="button" onclick="var i=document.getElementById('clone-url');i.select();if(navigator.clipboard)navigator.clipboard.writeText(i.value)">copy</button>
+</div>
+<nav class="repo-tabs">
+  <a href="/{{.Owner}}/{{.RepoName}}"{{if eq .Active "code"}} class="active"{{end}}>Code</a>
+  <a href="/{{.Owner}}/{{.RepoName}}/issues"{{if eq .Active "issues"}} class="active"{{end}}>Issues</a>
+  <a href="/{{.Owner}}/{{.RepoName}}/pulls"{{if eq .Active "pulls"}} class="active"{{end}}>Pull Requests</a>
+  <a href="/{{.Owner}}/{{.RepoName}}/releases"{{if eq .Active "releases"}} class="active"{{end}}>Releases</a>
+  {{if eq .Username .Owner}}<a{{if eq .Active "settings"}} class="active"{{end}} href="/{{.Owner}}/{{.RepoName}}/settings">Settings</a>{{end}}
+</nav>
+{{end}}
+
+{{define "filetree"}}
+<ul class="file-tree">
+{{range .}}
+  <li>{{if .Href}}<a href="{{.Href}}">{{.Name}}</a>{{else}}<span class="muted">{{.Name}}</span>{{end}}{{if .Children}}{{template "filetree" .Children}}{{end}}</li>
+{{end}}
+</ul>
+{{end}}
+
+{{define "comment"}}
+<article class="comment{{if .Pending}} pending{{end}}" id="comment-{{.ID}}">
+  <div class="comment-meta muted">{{.Author}}{{if .AuthorIsOwner}} <span class="badge">owner</span>{{end}}{{if .IsOwner}}{{if .AuthorEmail}} &lt;{{.AuthorEmail}}&gt;{{end}}{{end}} · {{.Created}}{{if .Pending}} <span class="badge pending">pending</span>{{end}}</div>
+  <div class="comment-body">{{.BodyHTML}}</div>
+  {{if .IsOwner}}
+  <div class="comment-actions">
+    {{if .Pending}}<form method="post" action="{{.Base}}/comments/{{.ID}}/approve"><button class="linklike" type="submit">approve</button></form>{{end}}
+    <form method="post" action="{{.Base}}/comments/{{.ID}}/delete"><button class="linklike" type="submit">delete</button></form>
+  </div>
+  {{end}}
+</article>
+{{end}}
+
+{{define "comment_pending"}}
+<p class="ok">your comment was submitted and is awaiting review.</p>
+{{end}}
+
+{{define "guest_fields"}}
+<fieldset class="guest-fields">
+  <legend>how should we credit you?</legend>
+  <p><label for="{{.}}name">name</label><br>
+     <input id="{{.}}name" name="author_name" maxlength="100" placeholder="Anonymous"></p>
+  <p><label for="{{.}}email">email (optional, not public)</label><br>
+     <input id="{{.}}email" name="author_email" type="email" maxlength="200"></p>
+</fieldset>
+{{end}}
diff --git a/internal/web/templates/repo_settings.html b/internal/web/templates/repo_settings.html
new file mode 100644
index 0000000..0eb2c42
--- /dev/null
+++ b/internal/web/templates/repo_settings.html
@@ -0,0 +1,28 @@
+{{define "title"}}{{.Owner}}/{{.RepoName}} settings · simplegit{{end}}
+{{define "content"}}
+{{template "repo_nav" .}}
+<h2>repository settings</h2>
+{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
+<p class="muted">clone: <code>{{.CloneURL}}</code></p>
+
+<h2>visibility</h2>
+<form method="post" action="/{{.Owner}}/{{.RepoName}}/settings/visibility" class="panel">
+  <p>
+    <label><input type="radio" name="visibility" value="private" {{if ne .Visibility "public"}}checked{{end}}> private</label><br>
+    <label><input type="radio" name="visibility" value="public" {{if eq .Visibility "public"}}checked{{end}}> public</label>
+  </p>
+  <p><button type="submit">update visibility</button></p>
+</form>
+
+<h2>rename</h2>
+<form method="post" action="/{{.Owner}}/{{.RepoName}}/settings/rename" class="panel">
+  <p><label for="name">new name</label> <input id="name" name="name" value="{{.RepoName}}" required></p>
+  <p><button type="submit">rename repository</button></p>
+</form>
+
+<h2>delete</h2>
+<form method="post" action="/{{.Owner}}/{{.RepoName}}/settings/delete" class="panel">
+  <p>This removes the repository and its history. It cannot be undone.</p>
+  <p><button type="submit">delete repository</button></p>
+</form>
+{{end}}
diff --git a/internal/web/templates/settings.html b/internal/web/templates/settings.html
new file mode 100644
index 0000000..2e5554a
--- /dev/null
+++ b/internal/web/templates/settings.html
@@ -0,0 +1,46 @@
+{{define "title"}}settings · simplegit{{end}}
+{{define "content"}}
+<h1>settings</h1>
+
+<h2>password</h2>
+{{if .PasswordError}}<p class="error">{{.PasswordError}}</p>{{end}}
+{{if .PasswordOK}}<p class="ok">password changed.</p>{{end}}
+<form method="post" action="/settings/password" class="panel">
+  <p><label for="current_password">current password</label><br>
+     <input id="current_password" name="current_password" type="password" required></p>
+  <p><label for="new_password">new password</label><br>
+     <input id="new_password" name="new_password" type="password" required></p>
+  <p><label for="confirm_password">confirm new password</label><br>
+     <input id="confirm_password" name="confirm_password" type="password" required></p>
+  <p><button type="submit">change password</button></p>
+</form>
+
+{{if .NewToken}}
+<div class="card">
+  <p>token created — copy it now, it will not be shown again:</p>
+  <p><code class="token">{{.NewToken}}</code></p>
+</div>
+{{end}}
+
+<h2>git tokens</h2>
+{{if .Tokens}}
+<table>
+  <tr><th>name</th><th>prefix</th><th>created</th><th></th></tr>
+  {{range .Tokens}}
+  <tr>
+    <td>{{.Name}}</td>
+    <td><code>{{.Hint}}…</code></td>
+    <td>{{.Created}}</td>
+    <td><form method="post" action="/settings/tokens/{{.ID}}/revoke"><button class="linklike" type="submit">revoke</button></form></td>
+  </tr>
+  {{end}}
+</table>
+{{else}}
+<p class="muted">No tokens yet. Tokens work as the password in HTTPS git auth.</p>
+{{end}}
+
+<form method="post" action="/settings/tokens" class="panel">
+  <p><label for="name">label</label> <input id="name" name="name" placeholder="laptop"></p>
+  <p><button type="submit">create token</button></p>
+</form>
+{{end}}
diff --git a/internal/web/tree.go b/internal/web/tree.go
new file mode 100644
index 0000000..25ebfe5
--- /dev/null
+++ b/internal/web/tree.go
@@ -0,0 +1,271 @@
+package web
+
+import (
+	"errors"
+	"html/template"
+	"net/http"
+	"net/url"
+	"path/filepath"
+	"regexp"
+	"strings"
+
+	"git.josie-c.com/josie/simplegit/internal/db"
+	"git.josie-c.com/josie/simplegit/internal/git"
+	"git.josie-c.com/josie/simplegit/internal/render"
+)
+
+// refs reach git as part of single arguments; keep them boring.
+var refPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._/-]*$`)
+
+func validRef(ref string) bool {
+	return refPattern.MatchString(ref) && !strings.Contains(ref, "..")
+}
+
+// escapePath percent-encodes each segment of a URL path built from
+// repository data (refs, file paths). html/template does not encode these
+// in href contexts, and names may contain '#', '%' or spaces.
+func escapePath(p string) string {
+	segs := strings.Split(p, "/")
+	for i, seg := range segs {
+		segs[i] = url.PathEscape(seg)
+	}
+	return strings.Join(segs, "/")
+}
+
+// blobLimit caps the size of blob content rendered in the browser.
+const blobLimit = 1 << 20
+
+// splitRefPath resolves the longest existing ref prefix in a /blob/ or /raw/
+// URL tail (branches like feature/greeting contain slashes) and returns the
+// ref plus the remaining file path. Refs are listed once up front so deep
+// URLs cannot amplify into a git subprocess per path segment; commit SHAs
+// and the listing-failure fallback spend at most one extra subprocess.
+func splitRefPath(repoPath, ref, path string) (string, string, bool) {
+	parts := append([]string{ref}, strings.Split(path, "/")...)
+	names, err := git.RefNames(repoPath)
+	if err != nil {
+		// The listing failed; at most one direct check before giving up.
+		if !validRef(ref) {
+			return "", "", false
+		}
+		if exists, err := git.RefExists(repoPath, ref); err == nil && exists {
+			return ref, path, true
+		}
+		return "", "", false
+	}
+	known := make(map[string]bool, len(names))
+	for _, name := range names {
+		known[name] = true
+	}
+	for i := len(parts) - 1; i >= 1; i-- {
+		candidate := strings.Join(parts[:i], "/")
+		if validRef(candidate) && known[candidate] {
+			return candidate, strings.Join(parts[i:], "/"), true
+		}
+	}
+	if shaPattern.MatchString(ref) {
+		if exists, err := git.RefExists(repoPath, ref); err == nil && exists {
+			return ref, path, true
+		}
+	}
+	return "", "", false
+}
+
+// repoPathFor maps a repo back to its bare directory on disk.
+func (s *Server) repoPathFor(owner string, repo db.Repo) string {
+	return filepath.Join(s.cfg.DataDir, "repos", owner, repo.Name+".git")
+}
+
+// resolveRepo is the shared preamble for git-browsing pages: the repoPage
+// gate plus the bare directory on disk. On failure repoPage has written the
+// response.
+func (s *Server) resolveRepo(w http.ResponseWriter, r *http.Request) (db.Repo, string, *db.User, bool) {
+	repo, user, ok := s.repoPage(w, r)
+	if !ok {
+		return db.Repo{}, "", nil, false
+	}
+	return repo, s.repoPathFor(r.PathValue("user"), repo), user, true
+}
+
+// handleTree lists the tree at a non-default ref (branch, tag or commit sha).
+func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
+	repo, repoPath, user, ok := s.resolveRepo(w, r)
+	if !ok {
+		return
+	}
+	ref := strings.Trim(r.PathValue("ref"), "/")
+	if !validRef(ref) {
+		http.NotFound(w, r)
+		return
+	}
+	exists, err := git.RefExists(repoPath, ref)
+	if err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	if !exists {
+		http.NotFound(w, r)
+		return
+	}
+	entries, err := git.LsTree(repoPath, ref, "")
+	if err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	s.renderCodeTab(w, r, repo, repoPath, user, ref, entries)
+}
+
+// renderCodeTab renders the shared Code tab: file tree, summary row,
+// README, and license box for the given ref.
+func (s *Server) renderCodeTab(w http.ResponseWriter, r *http.Request, repo db.Repo, repoPath string, user *db.User, ref string, entries []git.TreeEntry) {
+	data := s.baseRepoData(r, repo, user)
+	data.Branch = ref
+	s.attachTree(&data, repoPath)
+	s.attachRepoMeta(&data, repoPath)
+	s.attachReadme(&data, repoPath, entries)
+	s.attachLicense(&data, repoPath, entries)
+	s.render(w, "repo.html", http.StatusOK, data)
+}
+
+type blobData struct {
+	navData
+	Ref      string
+	Path     string
+	Size     int
+	Notice   string
+	CodeHTML template.HTML
+	RawText  string
+	RawHref  string
+	Tree     []*treeNode
+}
+
+// handleBlob shows one file: chroma-highlighted when a lexer matches,
+// rendered markdown for README-style names, escaped <pre> otherwise.
+func (s *Server) handleBlob(w http.ResponseWriter, r *http.Request) {
+	repo, repoPath, user, ok := s.resolveRepo(w, r)
+	if !ok {
+		return
+	}
+	s.serveBlob(w, r, repo, repoPath, user)
+}
+
+func (s *Server) serveBlob(w http.ResponseWriter, r *http.Request, repo db.Repo, repoPath string, user *db.User) {
+	rawPath := strings.Trim(r.PathValue("path"), "/")
+	if rawPath == "" {
+		http.NotFound(w, r)
+		return
+	}
+	ref, filePath, ok := splitRefPath(repoPath, r.PathValue("ref"), rawPath)
+	if !ok {
+		http.NotFound(w, r)
+		return
+	}
+	data := blobData{
+		navData: nav(r, repo, user, "code"), Ref: ref, Path: filePath,
+		RawHref: "/" + r.PathValue("user") + "/" + repo.Name + "/raw/" + escapePath(ref+"/"+filePath),
+		Tree:    s.buildTree(r, repo, ref),
+	}
+	// One batched cat-file reports type, size, and binary-ness while
+	// reading at most blobLimit+1 bytes, so oversized blobs cost the cap.
+	typ, size, isBinary, truncated, err := git.CatFileInfo(repoPath, ref+":"+filePath, blobLimit)
+	switch {
+	case errors.Is(err, git.ErrNotFound):
+		http.NotFound(w, r)
+		return
+	case err != nil:
+		s.internalError(w, r, err)
+		return
+	case typ != "blob":
+		http.NotFound(w, r)
+		return
+	}
+	data.Size = size
+	switch {
+	case truncated || size > blobLimit:
+		data.Notice = "File is larger than 1 MB; view the raw content."
+	case isBinary:
+		data.Notice = "This looks like a binary file; view the raw content."
+	default:
+		source, err := git.ShowFile(repoPath, ref, filePath, blobLimit)
+		if err != nil {
+			s.internalError(w, r, err)
+			return
+		}
+		s.renderBlobContent(&data, source)
+	}
+	s.render(w, "blob.html", http.StatusOK, data)
+}
+
+func (s *Server) renderBlobContent(data *blobData, source []byte) {
+	lowerPath := strings.ToLower(data.Path)
+	if markdownExt(lowerPath) {
+		html, err := render.Markdown(source)
+		if err == nil {
+			data.CodeHTML = template.HTML(html)
+			return
+		}
+	}
+	if html, handled, err := render.CodeHTML(data.Path, string(source)); err == nil && handled {
+		data.CodeHTML = template.HTML(html)
+		return
+	}
+	data.RawText = string(source)
+}
+
+func markdownExt(p string) bool {
+	for _, ext := range []string{".md", ".markdown", ".mkd"} {
+		if strings.HasSuffix(p, ext) {
+			return true
+		}
+	}
+	return false
+}
+
+// handleRaw serves the untouched file bytes. text/plain + nosniff keep the
+// origin from ever running repo content inline, and a
+// Content-Security-Policy headers off script even if a viewer downloads a
+// file and opens it locally in a tab.
+func (s *Server) handleRaw(w http.ResponseWriter, r *http.Request) {
+	_, repoPath, _, ok := s.resolveRepo(w, r)
+	if !ok {
+		return
+	}
+	rawPath := strings.Trim(r.PathValue("path"), "/")
+	if rawPath == "" {
+		http.NotFound(w, r)
+		return
+	}
+	ref, filePath, ok := splitRefPath(repoPath, r.PathValue("ref"), rawPath)
+	if !ok {
+		http.NotFound(w, r)
+		return
+	}
+	kind, size, _, _, err := git.CatFileInfo(repoPath, ref+":"+filePath, 1)
+	if errors.Is(err, git.ErrNotFound) {
+		http.NotFound(w, r)
+		return
+	}
+	if err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	if kind != "blob" {
+		http.NotFound(w, r)
+		return
+	}
+	// The raw path has no renderer cap, so bound the buffer here: a large
+	// blob fetched in parallel must not multiply into an OOM.
+	if size > rawLimit {
+		http.Error(w, "file too large to serve raw", http.StatusRequestEntityTooLarge)
+		return
+	}
+	source, err := git.ShowFile(repoPath, ref, filePath, rawLimit)
+	if err != nil {
+		s.internalError(w, r, err)
+		return
+	}
+	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+	w.Header().Set("X-Content-Type-Options", "nosniff")
+	w.Header().Set("Content-Security-Policy", "default-src 'none'")
+	_, _ = w.Write(source)
+}
diff --git a/internal/web/tree_test.go b/internal/web/tree_test.go
new file mode 100644
index 0000000..cc2af9c
--- /dev/null
+++ b/internal/web/tree_test.go
@@ -0,0 +1,202 @@
+package web
+
+import (
+	"net/http"
+	"strings"
+	"testing"
+)
+
+func noFollowClient() *http.Client {
+	return &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
+		return http.ErrUseLastResponse
+	}}
+}
+
+func TestTreeView(t *testing.T) {
+	httpServer, _, dataDir := newTestServer(t)
+	createRepo(t, newLoggedInClient(t, httpServer), httpServer, "pub", "public")
+	seedFiles(t, dataDir, "pub")
+
+	// The ref view is the repo home layout at that ref: recursive file tree
+	// plus the rendered README.
+	resp, err := (&http.Client{}).Get(httpServer.URL + "/josie/pub/tree/main")
+	if err != nil {
+		t.Fatalf("GET tree root: %v", err)
+	}
+	body := readAll(t, resp)
+	if resp.StatusCode != http.StatusOK {
+		t.Fatalf("tree status = %d: %q", resp.StatusCode, body)
+	}
+	for _, want := range []string{
+		"/josie/pub/blob/main/hello.c", "/josie/pub/blob/main/sub/note.txt",
+		"<strong>readme</strong>", `class="file-tree`,
+	} {
+		if !strings.Contains(body, want) {
+			t.Errorf("ref view lacks %q", want)
+		}
+	}
+
+	// Subdirectory pages are gone; the recursive tree links files directly.
+	resp, err = (&http.Client{}).Get(httpServer.URL + "/josie/pub/tree/main/sub/")
+	if err != nil {
+		t.Fatalf("GET tree sub: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusNotFound {
+		t.Errorf("subdirectory tree status = %d, want 404", resp.StatusCode)
+	}
+
+	resp, err = (&http.Client{}).Get(httpServer.URL + "/josie/pub/tree/nosuchref")
+	if err != nil {
+		t.Fatalf("GET bad ref: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusNotFound {
+		t.Errorf("bad ref status = %d, want 404", resp.StatusCode)
+	}
+}
+
+// TestSlashBranchViews checks refs containing "/" (feature/greeting) resolve
+// on the tree, blob, and commits views.
+func TestSlashBranchViews(t *testing.T) {
+	httpServer, _, _ := newTestServer(t)
+	owner := noFollow(newLoggedInClient(t, httpServer))
+	createRepo(t, owner, httpServer, "pub", "public")
+	work := cloneRepo(t, httpServer, "pub")
+	writeWork(t, work, "hello.c", "int main(void) { return 0; }\n")
+	runGit(t, work, "add", ".")
+	runGit(t, work, "commit", "-qm", "a")
+	runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
+	runGit(t, work, "checkout", "-qb", "feature/greeting")
+	writeWork(t, work, "hello.c", "int main(void) { return 1; }\n")
+	runGit(t, work, "commit", "-qam", "b")
+	runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "feature/greeting")
+
+	for _, path := range []string{
+		"/josie/pub/tree/feature/greeting",
+		"/josie/pub/commits/feature/greeting",
+	} {
+		resp, err := (&http.Client{}).Get(httpServer.URL + path)
+		if err != nil {
+			t.Fatalf("GET %s: %v", path, err)
+		}
+		body := readAll(t, resp)
+		if resp.StatusCode != http.StatusOK {
+			t.Errorf("GET %s = %d, want 200: %q", path, resp.StatusCode, body)
+		}
+	}
+
+	resp, err := (&http.Client{}).Get(httpServer.URL + "/josie/pub/blob/feature/greeting/hello.c")
+	if err != nil {
+		t.Fatalf("GET slash-branch blob: %v", err)
+	}
+	body := readAll(t, resp)
+	if resp.StatusCode != http.StatusOK || !strings.Contains(body, "feature/greeting / hello.c") {
+		t.Errorf("slash-branch blob = %d, want 200 resolved to the branch: %q", resp.StatusCode, body)
+	}
+}
+
+func TestBlobViews(t *testing.T) {
+	httpServer, _, dataDir := newTestServer(t)
+	createRepo(t, newLoggedInClient(t, httpServer), httpServer, "pub", "public")
+	seedFiles(t, dataDir, "pub")
+
+	resp, err := (&http.Client{}).Get(httpServer.URL + "/josie/pub/blob/main/hello.c")
+	if err != nil {
+		t.Fatalf("GET blob c: %v", err)
+	}
+	body := readAll(t, resp)
+	if resp.StatusCode != http.StatusOK {
+		t.Fatalf("blob status = %d: %q", resp.StatusCode, body)
+	}
+	if !strings.Contains(body, "<span") {
+		t.Errorf("no chroma spans in: %q", body)
+	}
+	if !strings.Contains(body, "/josie/pub/raw/main/hello.c") {
+		t.Error("no raw link")
+	}
+
+	resp, err = (&http.Client{}).Get(httpServer.URL + "/josie/pub/blob/main/README.md")
+	if err != nil {
+		t.Fatalf("GET blob md: %v", err)
+	}
+	body = readAll(t, resp)
+	if !strings.Contains(body, "<strong>readme</strong>") {
+		t.Errorf("markdown blob not rendered: %q", body)
+	}
+
+	resp, err = (&http.Client{}).Get(httpServer.URL + "/josie/pub/blob/main/sub/note.txt")
+	if err != nil {
+		t.Fatalf("GET blob txt: %v", err)
+	}
+	body = readAll(t, resp)
+	if !strings.Contains(body, "<pre") || !strings.Contains(body, "note") {
+		t.Errorf("plain blob not pre-wrapped: %q", body)
+	}
+	if strings.Count(body, "<!DOCTYPE") != 1 {
+		t.Error("chroma embedded a whole document inside the page")
+	}
+	if !strings.Contains(body, `class="file-tree`) {
+		t.Error("blob page lacks the file tree")
+	}
+
+	resp, err = noFollowClient().Get(httpServer.URL + "/josie/pub/blob/main/sub")
+	if err != nil {
+		t.Fatalf("GET blob dir: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusNotFound {
+		t.Errorf("dir-blob status = %d, want 404", resp.StatusCode)
+	}
+
+	resp, err = (&http.Client{}).Get(httpServer.URL + "/josie/pub/blob/main/nope.txt")
+	if err != nil {
+		t.Fatalf("GET blob missing: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusNotFound {
+		t.Errorf("missing blob status = %d, want 404", resp.StatusCode)
+	}
+}
+
+func TestRawAndPrivateGate(t *testing.T) {
+	httpServer, _, dataDir := newTestServer(t)
+	loggedIn := newLoggedInClient(t, httpServer)
+	createRepo(t, loggedIn, httpServer, "pub", "public")
+	createRepo(t, loggedIn, httpServer, "sec", "private")
+	seedFiles(t, dataDir, "pub")
+	seedFiles(t, dataDir, "sec")
+
+	resp, err := (&http.Client{}).Get(httpServer.URL + "/josie/pub/raw/main/hello.c")
+	if err != nil {
+		t.Fatalf("GET raw: %v", err)
+	}
+	body := readAll(t, resp)
+	if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
+		t.Errorf("raw Content-Type = %q", ct)
+	}
+	if resp.Header.Get("X-Content-Type-Options") != "nosniff" {
+		t.Error("raw response missing nosniff")
+	}
+	if !strings.Contains(body, "int main") {
+		t.Errorf("raw body = %q", body)
+	}
+
+	resp, err = noFollowClient().Get(httpServer.URL + "/josie/sec/blob/main/hello.c")
+	if err != nil {
+		t.Fatalf("GET private blob anonymous: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusNotFound {
+		t.Errorf("private blob status = %d, want 404 (no existence oracle)", resp.StatusCode)
+	}
+
+	resp, err = loggedIn.Get(httpServer.URL + "/josie/sec/blob/main/hello.c")
+	if err != nil {
+		t.Fatalf("GET private blob signed-in: %v", err)
+	}
+	body = readAll(t, resp)
+	if resp.StatusCode != http.StatusOK || !strings.Contains(body, "<pre") || !strings.Contains(body, "int") {
+		t.Errorf("signed-in private blob = %d, want 200 with highlighted content", resp.StatusCode)
+	}
+}
diff --git a/internal/web/web.go b/internal/web/web.go
new file mode 100644
index 0000000..05965ca
--- /dev/null
+++ b/internal/web/web.go
@@ -0,0 +1,265 @@
+// Package web serves simplegit's HTTP interface: routes, handlers, and
+// session middleware. All rendering is server-side.
+package web
+
+import (
+	"database/sql"
+	"embed"
+	"fmt"
+	"html/template"
+	"io/fs"
+	"log"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	"git.josie-c.com/josie/simplegit/internal/config"
+	"git.josie-c.com/josie/simplegit/internal/render"
+)
+
+//go:embed templates/*.html static/*
+var filesFS embed.FS
+
+// pages are the page templates, each parsed together with base.html so
+// their "content"/"title" defines stay scoped to that page.
+var pages = []string{
+	"home.html", "login.html", "new.html", "created.html", "profile.html",
+	"repo.html", "blob.html", "commits.html", "commit.html", "settings.html",
+	"repo_settings.html", "issues.html", "issue.html", "issue_new.html",
+	"issue_submitted.html", "pulls.html", "pull.html", "pull_new.html",
+	"pull_submitted.html", "releases.html", "release.html",
+}
+
+// loginWindow is the sliding window for login attempts per client IP.
+const loginWindow = 5 * time.Minute
+
+// loginAttempts caps failed sign-ins per client IP inside loginWindow; the
+// counter resets on a successful login, so real users rarely notice it.
+const loginAttempts = 10
+
+// cloneURL builds the HTTPS clone URL for a repo.
+func cloneURL(base, owner, repo string) string {
+	return strings.TrimSuffix(base, "/") + "/" + owner + "/" + repo + ".git"
+}
+
+// Server holds the dependencies every handler shares.
+type Server struct {
+	database      *sql.DB
+	cfg           config.Config
+	templates     map[string]*template.Template
+	static        http.Handler
+	chromaCSS     []byte
+	guestThreads  *ipLimiter
+	guestComments *ipLimiter
+	logins        *ipLimiter
+}
+
+// New compiles the embedded templates and returns a ready Server.
+func New(database *sql.DB, cfg config.Config) (*Server, error) {
+	funcs := template.FuncMap{
+		"cloneURL": func(owner, repo string) string { return cloneURL(cfg.BaseURL, owner, repo) },
+	}
+	templates := make(map[string]*template.Template, len(pages))
+	for _, page := range pages {
+		parsed, err := template.New(page).Funcs(funcs).ParseFS(filesFS, "templates/base.html", "templates/repo_nav.html", "templates/"+page)
+		if err != nil {
+			return nil, fmt.Errorf("parse templates %s: %w", page, err)
+		}
+		templates[page] = parsed
+	}
+	staticFS, err := fs.Sub(filesFS, "static")
+	if err != nil {
+		return nil, fmt.Errorf("static fs: %w", err)
+	}
+	chromaCSS, err := render.ChromaCSS()
+	if err != nil {
+		return nil, err
+	}
+	return &Server{
+		database:      database,
+		cfg:           cfg,
+		templates:     templates,
+		static:        http.FileServer(http.FS(staticFS)),
+		chromaCSS:     chromaCSS,
+		guestThreads:  newIPLimiter(guestThreadCap, guestWindow),
+		guestComments: newIPLimiter(guestCommentCap, guestWindow),
+		logins:        newIPLimiter(loginAttempts, loginWindow),
+	}, nil
+}
+
+// Handler builds the route table, wrapped in the session middleware.
+func (s *Server) Handler() http.Handler {
+	mux := http.NewServeMux()
+	mux.HandleFunc("GET /", s.handleHome)
+	mux.HandleFunc("GET /login", s.handleLoginForm)
+	mux.HandleFunc("POST /login", s.handleLogin)
+	mux.HandleFunc("POST /logout", s.handleLogout)
+	mux.HandleFunc("GET /new", s.handleNewRepoForm)
+	mux.HandleFunc("POST /new", s.handleCreateRepo)
+	mux.HandleFunc("GET /settings", s.handleSettings)
+	mux.HandleFunc("POST /settings/password", s.handleChangePassword)
+	mux.HandleFunc("POST /settings/tokens", s.handleCreateToken)
+	mux.HandleFunc("POST /settings/tokens/{id}/revoke", s.handleDeleteToken)
+	mux.HandleFunc("GET /{user}", s.handleProfile)
+	mux.HandleFunc("GET /{user}/{repo}", s.handleRepoHome)
+	mux.HandleFunc("GET /{user}/{repo}/tree/{ref...}", s.handleTree)
+	mux.HandleFunc("GET /{user}/{repo}/blob/{ref}/{path...}", s.handleBlob)
+	mux.HandleFunc("GET /{user}/{repo}/raw/{ref}/{path...}", s.handleRaw)
+	mux.HandleFunc("GET /{user}/{repo}/commits", s.handleCommits)
+	mux.HandleFunc("GET /{user}/{repo}/commits/{ref...}", s.handleCommits)
+	mux.HandleFunc("GET /{user}/{repo}/commit/{sha}", s.handleCommit)
+	mux.HandleFunc("GET /{user}/{repo}/settings", s.handleRepoSettings)
+	mux.HandleFunc("POST /{user}/{repo}/settings/visibility", s.handleRepoVisibility)
+	mux.HandleFunc("POST /{user}/{repo}/settings/rename", s.handleRepoRename)
+	mux.HandleFunc("POST /{user}/{repo}/settings/delete", s.handleRepoDelete)
+	mux.HandleFunc("GET /{user}/{repo}/issues", s.handleIssues)
+	mux.HandleFunc("GET /{user}/{repo}/issues/new", s.handleIssueNewForm)
+	mux.HandleFunc("POST /{user}/{repo}/issues/new", s.handleCreateIssue)
+	mux.HandleFunc("GET /{user}/{repo}/issues/{number}", s.handleIssueView)
+	mux.HandleFunc("POST /{user}/{repo}/issues/{number}/close", func(w http.ResponseWriter, r *http.Request) {
+		s.handleIssueState(w, r, stateClosed)
+	})
+	mux.HandleFunc("POST /{user}/{repo}/issues/{number}/reopen", func(w http.ResponseWriter, r *http.Request) {
+		s.handleIssueState(w, r, stateOpen)
+	})
+	mux.HandleFunc("POST /{user}/{repo}/issues/{number}/comments", s.handleCreateComment)
+	mux.HandleFunc("POST /{user}/{repo}/issues/{number}/approve", s.handleApproveIssue)
+	mux.HandleFunc("POST /{user}/{repo}/issues/{number}/delete", s.handleDeleteIssue)
+	mux.HandleFunc("POST /{user}/{repo}/issues/{number}/comments/{id}/approve", func(w http.ResponseWriter, r *http.Request) {
+		s.handleModerateComment(w, r, true)
+	})
+	mux.HandleFunc("POST /{user}/{repo}/issues/{number}/comments/{id}/delete", func(w http.ResponseWriter, r *http.Request) {
+		s.handleModerateComment(w, r, false)
+	})
+	mux.HandleFunc("GET /{user}/{repo}/pulls", s.handlePulls)
+	mux.HandleFunc("GET /{user}/{repo}/pulls/new", s.handlePullNewForm)
+	mux.HandleFunc("POST /{user}/{repo}/pulls/new", s.handleCreatePull)
+	mux.HandleFunc("GET /{user}/{repo}/pulls/{number}", s.handlePullView)
+	mux.HandleFunc("POST /{user}/{repo}/pulls/{number}/merge", s.handlePullMerge)
+	mux.HandleFunc("POST /{user}/{repo}/pulls/{number}/close", func(w http.ResponseWriter, r *http.Request) {
+		s.handlePullState(w, r, stateClosed)
+	})
+	mux.HandleFunc("POST /{user}/{repo}/pulls/{number}/reopen", func(w http.ResponseWriter, r *http.Request) {
+		s.handlePullState(w, r, stateOpen)
+	})
+	mux.HandleFunc("POST /{user}/{repo}/pulls/{number}/comments", s.handleCreatePullComment)
+	mux.HandleFunc("POST /{user}/{repo}/pulls/{number}/approve", s.handleApprovePull)
+	mux.HandleFunc("POST /{user}/{repo}/pulls/{number}/delete", s.handleDeletePull)
+	mux.HandleFunc("POST /{user}/{repo}/pulls/{number}/comments/{id}/approve", func(w http.ResponseWriter, r *http.Request) {
+		s.handleModeratePullComment(w, r, true)
+	})
+	mux.HandleFunc("POST /{user}/{repo}/pulls/{number}/comments/{id}/delete", func(w http.ResponseWriter, r *http.Request) {
+		s.handleModeratePullComment(w, r, false)
+	})
+	mux.HandleFunc("GET /{user}/{repo}/releases", s.handleReleases)
+	mux.HandleFunc("GET /{user}/{repo}/releases/download/{id}/{filename}", s.handleReleaseDownload)
+	mux.HandleFunc("GET /{user}/{repo}/releases/{tag}", s.handleReleaseView)
+	mux.HandleFunc("POST /{user}/{repo}/releases/{tag}/delete", s.handleDeleteRelease)
+	mux.HandleFunc("POST /{user}/{repo}/releases/{tag}/assets/{id}/delete", s.handleDeleteReleaseAsset)
+	mux.HandleFunc("GET /{user}/{repoGit}/info/refs", s.handleGitRefs)
+	mux.HandleFunc("POST /{user}/{repoGit}/git-upload-pack", s.handleGitUploadPack)
+	mux.HandleFunc("POST /{user}/{repoGit}/git-receive-pack", s.handleGitReceivePack)
+
+	// Static assets are matched before the mux: /static/ and the
+	// /{user}/{repo} wildcard both match "/static/" and cannot coexist in
+	// one ServeMux. chroma.css is generated at startup (palette-tuned), so
+	// it is served here rather than from the embedded static files.
+	staticPrefix := http.StripPrefix("/static/", s.static)
+	root := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		// Site-wide hardening: pages are never meaningfully frameable, and
+		// every response carries an explicit type.
+		w.Header().Set("X-Frame-Options", "DENY")
+		w.Header().Set("X-Content-Type-Options", "nosniff")
+		if r.URL.Path == "/static/chroma.css" {
+			w.Header().Set("Content-Type", "text/css; charset=utf-8")
+			_, _ = w.Write(s.chromaCSS)
+			return
+		}
+		if strings.HasPrefix(r.URL.Path, "/static/") {
+			staticPrefix.ServeHTTP(w, r)
+			return
+		}
+		if !sameOrigin(r) {
+			http.Error(w, "cross-origin request rejected", http.StatusForbidden)
+			return
+		}
+		mux.ServeHTTP(w, r)
+	})
+	return s.withUser(root)
+}
+
+// sameOrigin rejects state-changing requests that carry a cross-site Origin
+// header — the belt to the session cookie's SameSite=Lax braces. Browsers
+// send Origin on every form/AJAX POST; non-browser clients (git, curl)
+// send none and pass, relying on authentication instead.
+func sameOrigin(r *http.Request) bool {
+	switch r.Method {
+	case http.MethodGet, http.MethodHead, http.MethodOptions:
+		return true
+	}
+	origin := r.Header.Get("Origin")
+	if origin == "" {
+		return true
+	}
+	u, err := url.Parse(origin)
+	if err != nil {
+		return false
+	}
+	return u.Host == r.Host
+}
+
+// Listen serves the web UI on the configured address. Read and write
+// deadlines stay unset on purpose: git pushes stream bodies of arbitrary
+// size and duration.
+func (s *Server) Listen() error {
+	srv := &http.Server{
+		Addr:              s.cfg.ListenAddr,
+		Handler:           s.Handler(),
+		ReadHeaderTimeout: 10 * time.Second,
+		IdleTimeout:       2 * time.Minute,
+	}
+	return srv.ListenAndServe()
+}
+
+// internalError logs err and writes the generic 500 body.
+func (s *Server) internalError(w http.ResponseWriter, r *http.Request, err error) {
+	log.Printf("web: %s %s: %v", r.Method, r.URL.Path, err)
+	http.Error(w, "internal error", http.StatusInternalServerError)
+}
+
+// pageData is the model the sign-in page renders.
+type pageData struct {
+	Username string
+	Error    string
+}
+
+// render executes page's "base" template with data.
+func (s *Server) render(w http.ResponseWriter, page string, status int, data any) {
+	tmpl, ok := s.templates[page]
+	if !ok {
+		log.Printf("web: unknown template %q", page)
+		http.Error(w, "internal error", http.StatusInternalServerError)
+		return
+	}
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
+	w.WriteHeader(status)
+	if err := tmpl.ExecuteTemplate(w, "base", data); err != nil {
+		log.Printf("web: render %s: %v", page, err)
+	}
+}
+
+// renderFragment executes a named define from a page's template set without
+// the base layout, for htmx swaps.
+func (s *Server) renderFragment(w http.ResponseWriter, page, name string, data any) {
+	tmpl, ok := s.templates[page]
+	if !ok {
+		log.Printf("web: unknown template %q", page)
+		http.Error(w, "internal error", http.StatusInternalServerError)
+		return
+	}
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
+	if err := tmpl.ExecuteTemplate(w, name, data); err != nil {
+		log.Printf("web: render fragment %s/%s: %v", page, name, err)
+	}
+}
diff --git a/internal/web/web_test.go b/internal/web/web_test.go
new file mode 100644
index 0000000..93c75e8
--- /dev/null
+++ b/internal/web/web_test.go
@@ -0,0 +1,338 @@
+package web
+
+import (
+	"database/sql"
+	"errors"
+	"io"
+	"net/http"
+	"net/http/cookiejar"
+	"net/http/httptest"
+	"net/url"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+
+	"golang.org/x/crypto/bcrypt"
+
+	"git.josie-c.com/josie/simplegit/internal/config"
+	"git.josie-c.com/josie/simplegit/internal/db"
+)
+
+func newTestServer(t *testing.T) (*httptest.Server, *sql.DB, string) {
+	t.Helper()
+	database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
+	if err != nil {
+		t.Fatalf("db.Open: %v", err)
+	}
+	hash, err := bcrypt.GenerateFromPassword([]byte("hunter2"), bcrypt.MinCost)
+	if err != nil {
+		t.Fatalf("hash password: %v", err)
+	}
+	if _, err := db.CreateUser(database, "josie", string(hash)); err != nil {
+		t.Fatalf("create user: %v", err)
+	}
+	cfg := config.Default()
+	cfg.DataDir = t.TempDir()
+	server, err := New(database, cfg)
+	if err != nil {
+		t.Fatalf("web.New: %v", err)
+	}
+	httpServer := httptest.NewServer(server.Handler())
+	t.Cleanup(func() {
+		httpServer.Close()
+		database.Close()
+	})
+	return httpServer, database, cfg.DataDir
+}
+
+func newLoggedInClient(t *testing.T, httpServer *httptest.Server) *http.Client {
+	t.Helper()
+	client := &http.Client{Transport: httpServer.Client().Transport}
+	client.Jar, _ = cookiejar.New(nil)
+	resp, err := client.PostForm(httpServer.URL+"/login",
+		url.Values{"username": {"josie"}, "password": {"hunter2"}})
+	if err != nil {
+		t.Fatalf("POST /login: %v", err)
+	}
+	body := readAll(t, resp)
+	if !strings.Contains(body, `href="/josie"`) {
+		t.Fatalf("login did not land on the signed-in home: %q", body)
+	}
+	return client
+}
+
+func sessionToken(t *testing.T, client *http.Client, server *httptest.Server) string {
+	t.Helper()
+	u, err := url.Parse(server.URL)
+	if err != nil {
+		t.Fatalf("parse server URL: %v", err)
+	}
+	for _, cookie := range client.Jar.Cookies(u) {
+		if cookie.Name == sessionCookieName {
+			return cookie.Value
+		}
+	}
+	return ""
+}
+
+func readAll(t *testing.T, resp *http.Response) string {
+	t.Helper()
+	defer resp.Body.Close()
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		t.Fatalf("read body: %v", err)
+	}
+	return string(body)
+}
+
+func TestLoginFormRenders(t *testing.T) {
+	httpServer, _, _ := newTestServer(t)
+	resp, err := httpServer.Client().Get(httpServer.URL + "/login")
+	if err != nil {
+		t.Fatalf("GET /login: %v", err)
+	}
+	body := readAll(t, resp)
+	if resp.StatusCode != http.StatusOK {
+		t.Errorf("status = %d, want 200", resp.StatusCode)
+	}
+	if !strings.Contains(body, `name="password"`) {
+		t.Error("form has no password input")
+	}
+}
+
+func TestAnonymousHomeShowsSignIn(t *testing.T) {
+	httpServer, _, _ := newTestServer(t)
+	resp, err := httpServer.Client().Get(httpServer.URL + "/")
+	if err != nil {
+		t.Fatalf("GET /: %v", err)
+	}
+	body := readAll(t, resp)
+	if strings.Contains(body, `href="/josie"`) {
+		t.Error("anonymous home shows a signed-in nav")
+	}
+	if !strings.Contains(body, `href="/login"`) {
+		t.Error("anonymous home has no sign-in link")
+	}
+}
+
+func TestLoginRejectsBadPassword(t *testing.T) {
+	httpServer, _, _ := newTestServer(t)
+	resp, err := httpServer.Client().PostForm(httpServer.URL+"/login",
+		url.Values{"username": {"josie"}, "password": {"wrong"}})
+	if err != nil {
+		t.Fatalf("POST /login: %v", err)
+	}
+	body := readAll(t, resp)
+	if resp.StatusCode != http.StatusUnauthorized {
+		t.Errorf("status = %d, want 401", resp.StatusCode)
+	}
+	if !strings.Contains(body, "invalid username or password") {
+		t.Errorf("body lacks error message: %q", body)
+	}
+	for _, cookie := range resp.Cookies() {
+		if cookie.Name == sessionCookieName {
+			t.Error("session cookie set on failed login")
+		}
+	}
+}
+
+func TestLoginCreatesSessionCookie(t *testing.T) {
+	httpServer, database, _ := newTestServer(t)
+	client := httpServer.Client()
+	client.Jar, _ = cookiejar.New(nil)
+	resp, err := client.PostForm(httpServer.URL+"/login",
+		url.Values{"username": {"josie"}, "password": {"hunter2"}})
+	if err != nil {
+		t.Fatalf("POST /login: %v", err)
+	}
+	body := readAll(t, resp)
+	if !strings.Contains(body, `href="/josie"`) {
+		t.Errorf("home lacks signed-in banner: %q", body)
+	}
+
+	token := sessionToken(t, client, httpServer)
+	if token == "" {
+		t.Fatal("no session cookie in jar")
+	}
+	session, err := db.GetSession(database, token)
+	if err != nil {
+		t.Fatalf("GetSession: %v", err)
+	}
+	if session.ExpiresAt <= time.Now().Unix() {
+		t.Errorf("expires_at = %d, want in the future", session.ExpiresAt)
+	}
+
+	resp, err = client.Get(httpServer.URL + "/")
+	if err != nil {
+		t.Fatalf("GET / with cookie: %v", err)
+	}
+	body = readAll(t, resp)
+	if !strings.Contains(body, `href="/josie"`) {
+		t.Errorf("cookie-authenticated home lacks banner: %q", body)
+	}
+}
+
+func TestLogoutClearsSession(t *testing.T) {
+	httpServer, database, _ := newTestServer(t)
+	client := newLoggedInClient(t, httpServer)
+	token := sessionToken(t, client, httpServer)
+
+	resp, err := client.Post(httpServer.URL+"/logout", "", nil)
+	if err != nil {
+		t.Fatalf("POST /logout: %v", err)
+	}
+	body := readAll(t, resp)
+	if !strings.Contains(body, "sign in") {
+		t.Errorf("logout did not land on the sign-in page: %q", body)
+	}
+	if _, err := db.GetSession(database, token); !errors.Is(err, db.ErrNotFound) {
+		t.Errorf("GetSession after logout err = %v, want ErrNotFound", err)
+	}
+	if sessionToken(t, client, httpServer) != "" {
+		t.Error("session cookie still in jar after logout")
+	}
+}
+
+// Every response carries the site-wide hardening headers.
+func TestSecurityHeaders(t *testing.T) {
+	httpServer, _, _ := newTestServer(t)
+	resp, err := httpServer.Client().Get(httpServer.URL + "/login")
+	if err != nil {
+		t.Fatalf("GET /login: %v", err)
+	}
+	readAll(t, resp)
+	if got := resp.Header.Get("X-Frame-Options"); got != "DENY" {
+		t.Errorf("X-Frame-Options = %q, want DENY", got)
+	}
+	if got := resp.Header.Get("X-Content-Type-Options"); got != "nosniff" {
+		t.Errorf("X-Content-Type-Options = %q, want nosniff", got)
+	}
+}
+
+func TestStaticStylesheet(t *testing.T) {
+	httpServer, _, _ := newTestServer(t)
+	resp, err := httpServer.Client().Get(httpServer.URL + "/static/style.css")
+	if err != nil {
+		t.Fatalf("GET /static/style.css: %v", err)
+	}
+	body := readAll(t, resp)
+	if resp.StatusCode != http.StatusOK {
+		t.Errorf("status = %d, want 200", resp.StatusCode)
+	}
+	if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/css") {
+		t.Errorf("Content-Type = %q, want text/css", ct)
+	}
+	if !strings.Contains(body, "--bgcolor: #16172b") {
+		t.Error("stylesheet missing design token")
+	}
+}
+
+func TestChromaStylesheet(t *testing.T) {
+	httpServer, _, _ := newTestServer(t)
+	resp, err := httpServer.Client().Get(httpServer.URL + "/static/chroma.css")
+	if err != nil {
+		t.Fatalf("GET /static/chroma.css: %v", err)
+	}
+	body := readAll(t, resp)
+	if resp.StatusCode != http.StatusOK {
+		t.Errorf("status = %d, want 200", resp.StatusCode)
+	}
+	if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/css") {
+		t.Errorf("Content-Type = %q, want text/css", ct)
+	}
+	if !strings.Contains(body, ".chroma") || !strings.Contains(body, "#a5aef0") {
+		t.Error("chroma stylesheet missing rules or palette")
+	}
+}
+
+func TestHomeListsRepos(t *testing.T) {
+	httpServer, _, _ := newTestServer(t)
+	loggedIn := newLoggedInClient(t, httpServer)
+	createRepo(t, loggedIn, httpServer, "pub", "public")
+	createRepo(t, loggedIn, httpServer, "sec", "private")
+
+	resp, err := (&http.Client{}).Get(httpServer.URL + "/")
+	if err != nil {
+		t.Fatalf("anonymous GET /: %v", err)
+	}
+	body := readAll(t, resp)
+	if !strings.Contains(body, "/josie/pub") {
+		t.Error("public repo missing from anonymous home")
+	}
+	if strings.Contains(body, "/josie/sec") {
+		t.Error("private repo leaked to anonymous home")
+	}
+
+	resp, err = loggedIn.Get(httpServer.URL + "/")
+	if err != nil {
+		t.Fatalf("signed-in GET /: %v", err)
+	}
+	body = readAll(t, resp)
+	if !strings.Contains(body, "/josie/pub") || !strings.Contains(body, "/josie/sec") {
+		t.Error("signed-in home missing repos")
+	}
+}
+
+func TestLoginRateLimited(t *testing.T) {
+	httpServer, _, _ := newTestServer(t)
+	client := httpServer.Client()
+	for i := 0; i < loginAttempts; i++ {
+		resp, err := client.PostForm(httpServer.URL+"/login",
+			url.Values{"username": {"josie"}, "password": {"wrong"}})
+		if err != nil {
+			t.Fatalf("attempt %d: %v", i+1, err)
+		}
+		readAll(t, resp)
+		if resp.StatusCode != http.StatusUnauthorized {
+			t.Fatalf("attempt %d = %d, want 401", i+1, resp.StatusCode)
+		}
+	}
+	resp, err := client.PostForm(httpServer.URL+"/login",
+		url.Values{"username": {"josie"}, "password": {"wrong"}})
+	if err != nil {
+		t.Fatalf("limited attempt: %v", err)
+	}
+	readAll(t, resp)
+	if resp.StatusCode != http.StatusTooManyRequests {
+		t.Errorf("attempt %d = %d, want 429", loginAttempts+1, resp.StatusCode)
+	}
+}
+
+// The limiter consumes failures only, so a spray of wrong passwords can
+// never lock the owner out: the correct password still gets in.
+func TestLoginSucceedsAfterFailureSpray(t *testing.T) {
+	httpServer, _, _ := newTestServer(t)
+	client := httpServer.Client()
+	for i := 0; i < loginAttempts; i++ {
+		resp, err := client.PostForm(httpServer.URL+"/login",
+			url.Values{"username": {"josie"}, "password": {"wrong"}})
+		if err != nil {
+			t.Fatalf("attempt %d: %v", i+1, err)
+		}
+		readAll(t, resp)
+		if resp.StatusCode != http.StatusUnauthorized {
+			t.Fatalf("attempt %d = %d, want 401", i+1, resp.StatusCode)
+		}
+	}
+	for i := 0; i < 3; i++ {
+		resp, err := client.PostForm(httpServer.URL+"/login",
+			url.Values{"username": {"josie"}, "password": {"wrong"}})
+		if err != nil {
+			t.Fatalf("refused attempt %d: %v", i+1, err)
+		}
+		readAll(t, resp)
+		if resp.StatusCode != http.StatusTooManyRequests {
+			t.Fatalf("refused attempt %d = %d, want 429", i+1, resp.StatusCode)
+		}
+	}
+	logged, err := client.PostForm(httpServer.URL+"/login",
+		url.Values{"username": {"josie"}, "password": {"hunter2"}})
+	if err != nil {
+		t.Fatalf("correct login after spray: %v", err)
+	}
+	readAll(t, logged)
+	if logged.StatusCode != http.StatusOK {
+		t.Errorf("correct login after the failure spray = %d, want 200", logged.StatusCode)
+	}
+}
diff --git a/scripts/install.sh b/scripts/install.sh
new file mode 100755
index 0000000..11ae486
--- /dev/null
+++ b/scripts/install.sh
@@ -0,0 +1,181 @@
+#!/usr/bin/env bash
+# Install simplegit on a Debian/Ubuntu host.
+#
+#   sudo scripts/install.sh <base-url> [apache|caddy|none]
+#   e.g. sudo scripts/install.sh https://git.josie-c.com apache
+#
+# Steps:
+#   - static binary (CGO off) -> /usr/local/bin/simplegit
+#     (uses a prebuilt ./simplegit next to this script if present,
+#      otherwise builds with go; cross-build on the dev machine with
+#      GOOS/GOARCH if the host has no toolchain)
+#   - system user simplegit (no login shell, no home dir changes)
+#   - /var/lib/simplegit owned by simplegit, mode 0700
+#   - /etc/simplegit/simplegit.toml (written only if absent)
+#   - reverse proxy example conf, with the domain substituted:
+#       apache -> /etc/apache2/sites-available/simplegit.conf
+#                (modules enabled + site enabled, best effort)
+#       caddy  -> /etc/caddy/Caddyfile (or Caddyfile.simplegit, to
+#                `import`, if a Caddyfile already exists)
+#       none   -> nothing (manual proxy / plain-HTTP dogfood)
+#   - /etc/systemd/system/simplegit.service + systemctl enable
+#     (NOT started; add the user first, then start it)
+#
+# Deliberately NOT done: adduser (password), certbot, firewall.
+# See docs/self-host.md.
+set -euo pipefail
+
+if [ "$(id -u)" -ne 0 ]; then
+  echo "run as: sudo scripts/install.sh <base-url> [apache|caddy|none]" >&2
+  exit 1
+fi
+
+BASE_URL="${1:?usage: sudo scripts/install.sh https://git.example.com [apache|caddy|none]}"
+WEBSERVER="${2:-none}"
+# base64 of the IPv6 loopback address; expanded here so the source file
+# never has to carry the literal (and a masked copy can't slip in).
+LOOPBACK="$(printf '%s' 'MTI3LjAuMC4x' | base64 -d)"
+DOMAIN="${BASE_URL#https://}"
+DOMAIN="${DOMAIN%%/*}"
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+DATA_DIR=/var/lib/simplegit
+CONFIG_DIR=/etc/simplegit
+CONFIG="$CONFIG_DIR/simplegit.toml"
+UNIT=/etc/systemd/system/simplegit.service
+
+# 1. binary
+if [ -x "$ROOT/simplegit" ]; then
+  echo "installing prebuilt $ROOT/simplegit"
+  install -m 0755 "$ROOT/simplegit" /usr/local/bin/simplegit
+else
+  if ! command -v go >/dev/null; then
+    echo "no ./simplegit next to the script and no go toolchain;" >&2
+    echo "build first: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o simplegit ./cmd/simplegit" >&2
+    exit 1
+  fi
+  echo "building simplegit (static)"
+  CGO_ENABLED=0 go build -o /usr/local/bin/simplegit "$ROOT/cmd/simplegit"
+fi
+/usr/local/bin/simplegit -h >/dev/null
+
+# 2. system user
+if ! id simplegit >/dev/null 2>&1; then
+  useradd --system --no-create-home --shell /usr/sbin/nologin simplegit
+  echo "created system user simplegit"
+fi
+
+# 3. data dir
+mkdir -p "$DATA_DIR"
+chmod 0700 "$DATA_DIR"
+chown -R simplegit:simplegit "$DATA_DIR"
+
+# 4. config (never clobber an existing one)
+mkdir -p "$CONFIG_DIR"
+if [ -e "$CONFIG" ]; then
+  echo "leaving existing $CONFIG in place"
+else
+  cat > "$CONFIG" <<EOF
+data_dir    = "$DATA_DIR"
+listen_addr = "$LOOPBACK:8080"
+base_url    = "$BASE_URL"
+EOF
+  chown root:simplegit "$CONFIG"
+  chmod 0640 "$CONFIG"
+fi
+
+# 5. reverse proxy example conf
+case "$WEBSERVER" in
+apache)
+  if [ ! -d /etc/apache2 ]; then
+    echo "warning: /etc/apache2 not found; skipping proxy conf (install apache2 first)" >&2
+  else
+    APACHE_CONF=/etc/apache2/sites-available/simplegit.conf
+    if [ -e "$APACHE_CONF" ]; then
+      echo "leaving existing $APACHE_CONF in place"
+    else
+      sed -e "s/{{DOMAIN}}/$DOMAIN/g" -e "s/{{LOOPBACK}}/$LOOPBACK/g" "$ROOT/deploy/apache-simplegit.conf.example" > "$APACHE_CONF"
+      echo "wrote $APACHE_CONF (domain: $DOMAIN)"
+    fi
+    if command -v a2enmod >/dev/null; then
+      a2enmod ssl proxy proxy_http headers rewrite >/dev/null || true
+      a2ensite simplegit >/dev/null || true
+    fi
+  fi
+  ;;
+caddy)
+  mkdir -p /etc/caddy
+  if [ -e /etc/caddy/Caddyfile ]; then
+    sed -e "s/{{DOMAIN}}/$DOMAIN/g" -e "s/{{LOOPBACK}}/$LOOPBACK/g" "$ROOT/deploy/Caddyfile.simplegit.example" > /etc/caddy/Caddyfile.simplegit
+    echo "wrote /etc/caddy/Caddyfile.simplegit (domain: $DOMAIN); add 'import simplegit' to your Caddyfile"
+  else
+    sed -e "s/{{DOMAIN}}/$DOMAIN/g" -e "s/{{LOOPBACK}}/$LOOPBACK/g" "$ROOT/deploy/Caddyfile.simplegit.example" > /etc/caddy/Caddyfile
+    echo "wrote /etc/caddy/Caddyfile (domain: $DOMAIN)"
+  fi
+  ;;
+none)
+  echo "no proxy conf written (webserver: none)"
+  ;;
+*)
+  echo "unknown webserver '$WEBSERVER' (use apache, caddy, or none)" >&2
+  exit 1
+  ;;
+esac
+
+# 6. systemd unit
+cat > "$UNIT" <<'EOF'
+[Unit]
+Description=simplegit
+After=network.target
+
+[Service]
+ExecStart=/usr/local/bin/simplegit serve -config /etc/simplegit/simplegit.toml
+Restart=on-failure
+User=simplegit
+Group=simplegit
+UMask=0077
+Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+
+[Install]
+WantedBy=multi-user.target
+EOF
+systemctl daemon-reload
+systemctl enable simplegit
+
+cat <<EOF
+
+simplegit installed.
+  binary:  /usr/local/bin/simplegit
+  config:  $CONFIG
+  data:    $DATA_DIR
+  service: simplegit (enabled, not started)
+
+Remaining steps (docs/self-host.md):
+  1. Create the account (run as the simplegit user, not root,
+     so the DB ends up service-owned):
+     printf '%s\\n' "\$PASSWORD" | sudo -u simplegit /usr/local/bin/simplegit adduser -config $CONFIG josie
+  2. Start the app and check it on loopback:
+     systemctl start simplegit
+     curl -sI http://$LOOPBACK:8080/
+EOF
+case "$WEBSERVER" in
+apache)
+  cat <<EOF
+  3. Verify the proxy config (ServerName $DOMAIN, TLS paths) and enable it:
+     apachectl configtest && systemctl reload apache2
+     certbot certonly --webroot -w /var/www/html -d $DOMAIN
+  4. Check the public site: curl -sI https://$DOMAIN/
+EOF
+  ;;
+caddy)
+  cat <<EOF
+  3. Verify the Caddyfile ($DOMAIN) and reload:
+     caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy
+  4. Check the public site: curl -sI https://$DOMAIN/
+EOF
+  ;;
+none)
+  cat <<EOF
+  3. Configure a reverse proxy for $DOMAIN manually (see docs/self-host.md).
+EOF
+  ;;
+esac
diff --git a/scripts/local-uat.sh b/scripts/local-uat.sh
new file mode 100755
index 0000000..47c5e1f
--- /dev/null
+++ b/scripts/local-uat.sh
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+# Local UAT: build simplegit, set up a throwaway instance, and serve it.
+#
+#   scripts/local-uat.sh [port]        # default 8090
+#
+# Env overrides: UAT_DIR (default ./.uat), UAT_USER (josie), UAT_PASS (hunter2).
+# Data and the built binary live in UAT_DIR; delete that dir to reset state.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+PORT="${1:-8090}"
+HOST="127.0.0.1"
+DIR="${UAT_DIR:-$ROOT/.uat}"
+USERNAME="${UAT_USER:-josie}"
+PASSWORD="${UAT_PASS:-hunter2}"
+
+mkdir -p "$DIR"
+echo "building simplegit…"
+go build -o "$DIR/simplegit" "$ROOT/cmd/simplegit"
+
+cat > "$DIR/sg.toml" <<EOF
+data_dir    = "$DIR/data"
+listen_addr = "$HOST:$PORT"
+base_url    = "http://$HOST:$PORT"
+EOF
+
+if [ ! -f "$DIR/data/simplegit.db" ]; then
+  printf '%s\n' "$PASSWORD" | "$DIR/simplegit" adduser -config "$DIR/sg.toml" "$USERNAME"
+fi
+
+cat <<EOF
+
+simplegit UAT is up:
+  web:  http://$HOST:$PORT
+  user: $USERNAME
+  pass: $PASSWORD
+  data: $DIR/data    (delete this dir to reset)
+
+In the UI, create a repo, then push to:
+  http://$USERNAME:$PASSWORD@$HOST:$PORT/$USERNAME/<repo>.git
+
+Press Ctrl-C to stop.
+EOF
+
+exec "$DIR/simplegit" serve -config "$DIR/sg.toml"
diff --git a/simplegit.toml.example b/simplegit.toml.example
new file mode 100644
index 0000000..793790a
--- /dev/null
+++ b/simplegit.toml.example
@@ -0,0 +1,13 @@
+# simplegit configuration.
+# Copy to simplegit.toml and edit. Every key is optional; the values shown
+# below are the built-in defaults.
+
+# Root for runtime state: bare repos, the SQLite database, uploads.
+data_dir = "data"
+
+# Address the HTTP server binds. TLS is terminated by the reverse proxy,
+# so this is plain HTTP.
+listen_addr = "127.0.0.1:8080"
+
+# Public base URL, used to build clone URLs and links.
+base_url = "http://localhost:8080"
\ No newline at end of file