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