penguin/flatgit

flatgit showcase

commit e7920416df1fd5a5ec6ec046a931cef5232b08af

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-06-24T05:23:48Z
subjectGeneratee commit tree files
commit e7920416df1fd5a5ec6ec046a931cef5232b08af
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-06-24T05:23:48Z

    Generatee commit tree files
---
 Makefile                            |   2 +-
 internal/buildinfo/buildinfo_gen.go |   4 +-
 internal/render/manifest.go         |  39 ++++----
 internal/render/render.go           |  64 ++++++-------
 internal/render/rtree.go            | 179 ++++++++++++++++++++++++++++++++++++
 internal/render/templates.go        |   4 +-
 internal/server/server.go           |  41 ++++++++-
 internal/webhook/webhook.go         |   6 ++
 8 files changed, 281 insertions(+), 58 deletions(-)

diff --git a/Makefile b/Makefile
index ebcb615..213aaf3 100644
--- a/Makefile
+++ b/Makefile
@@ -60,7 +60,7 @@ fmt:
 	gofmt -w ./cmd ./internal
 
 run: build
-	$(BIN_DIR)/flatgit daemon -c examples/tinyproxy.json --v=2
+	$(BIN_DIR)/flatgit daemon -c examples/tinyproxy.json
 
 vet:
 	go vet ./...
diff --git a/internal/buildinfo/buildinfo_gen.go b/internal/buildinfo/buildinfo_gen.go
index 5bf6f03..e3b58e6 100644
--- a/internal/buildinfo/buildinfo_gen.go
+++ b/internal/buildinfo/buildinfo_gen.go
@@ -2,6 +2,6 @@ package buildinfo
 
 const (
     Version     = "dev"
-    GitRevision = "748df2db506f433b5f56a298d27f616085da27aa"
-    Timestamp   = "20260622.100339"
+    GitRevision = "c61d98835bd35fc99be5c16051c45b2b87056a5e"
+    Timestamp   = "20260624.051331"
 )
diff --git a/internal/render/manifest.go b/internal/render/manifest.go
index c702e56..9c5b39e 100644
--- a/internal/render/manifest.go
+++ b/internal/render/manifest.go
@@ -2,7 +2,6 @@ package render
 
 import (
 	"path"
-	"strings"
 	"time"
 
 	"github.com/tgckpg/flatgit/internal/config"
@@ -36,19 +35,22 @@ type ManifestRepository struct {
 }
 
 type ManifestHumanRoutes struct {
-	Index string `json:"index"`
-	Log   string `json:"log"`
-	Refs  string `json:"refs"`
+	Index  string `json:"index"`
+	Log    string `json:"log"`
+	Refs   string `json:"refs"`
+	Tree   string `json:"tree"`
+	Blob   string `json:"blob"`
+	Commit string `json:"commit"`
 }
 
 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"`
+	Self    string  `json:"self"`
+	Refs    string  `json:"refs"`
+	Commits string  `json:"commits"`
+	Tree    string  `json:"tree"`
+	Commit  string  `json:"commit"`
+	Blob    *string `json:"blob,omitempty"`
+	Raw     string  `json:"raw"`
 }
 
 type ManifestCapabilities struct {
@@ -77,20 +79,23 @@ func NewManifest(repo config.Repo, defaultBranch string, defaultCommit string) M
 			DefaultCommit:  defaultCommit,
 			DefaultRefSlug: refSlug(repo.DefaultBranch),
 			SitePath:       repo.RepoBase(),
-			OwnerSitePath:  path.Dir(strings.TrimSuffix(repo.RepoBase(), "/")),
+			OwnerSitePath:  path.Dir(repo.RepoBase()),
 		},
 		Human: ManifestHumanRoutes{
-			Index: "./index.html",
-			Log:   "./log.html",
-			Refs:  "./refs.html",
+			Index:  "./index.html",
+			Log:    "./log.html",
+			Refs:   "./refs.html",
+			Tree:   "./tree/{ref}/",
+			Blob:   "./blob/{ref}/{path}.html",
+			Commit: "./commit/{sha}.html",
 		},
 		Machine: ManifestAPIRoutes{
 			Self:    "./manifest.json",
 			Refs:    "./refs.json",
 			Commits: "./commits.json",
-			Tree:    "./tree/{ref}.json",
+			Tree:    "./tree/{ref}/tree.json",
 			Commit:  "./commit/{sha}.json",
-			Blob:    "./blob/{ref}/{path}.json",
+			Blob:    nil,
 			Raw:     "./raw/{ref}/{path}",
 		},
 		Capabilities: ManifestCapabilities{
diff --git a/internal/render/render.go b/internal/render/render.go
index da48675..c634bf3 100644
--- a/internal/render/render.go
+++ b/internal/render/render.go
@@ -13,7 +13,6 @@ import (
 	"strconv"
 	"strings"
 	"time"
-	"unicode/utf8"
 
 	"github.com/tgckpg/flatgit/internal/config"
 	"github.com/tgckpg/flatgit/internal/gitcmd"
@@ -107,7 +106,14 @@ func (r *Renderer) RenderRepo(ctx context.Context, repo config.Repo) error {
 		return err
 	}
 
-	page := basePage{RepoManifest: manifest, GeneratedAt: manifest.GeneratedAt.Format(time.RFC3339)}
+	branchSlug := refSlug(repo.DefaultBranch)
+
+	page := basePage{
+		RepoManifest: manifest,
+		Ref:          branchSlug,
+		GeneratedAt:  manifest.GeneratedAt.Format(time.RFC3339),
+	}
+
 	if err := renderTemplate(filepath.Join(next, "index.html"), indexTemplate, struct {
 		basePage
 		Refs    []RefInfo
@@ -129,56 +135,43 @@ func (r *Renderer) RenderRepo(ctx context.Context, repo config.Repo) error {
 		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 {
+	if err := r.renderCommitFiles(ctx, repo, next, commit, branchSlug, repo.DefaultBranch, page); 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)
+		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
 		}
+
+		commitPage := page
+		commitPage.Ref = c.Hash
+
 		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 {
+		}{commitPage, c, show}); 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 {
+		if err := writeJSON(filepath.Join(next, "commit", c.Hash+".json"), c); 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 {
+		if err := r.renderCommitFiles(ctx, repo, next, c.Hash, c.Hash, c.Hash, page); err != nil {
 			return err
 		}
 	}
@@ -285,6 +278,7 @@ func (r *Renderer) tree(ctx context.Context, repo config.Repo, ref string) ([]Tr
 
 type basePage struct {
 	RepoManifest Manifest
+	Ref          string
 	GeneratedAt  string
 }
 
diff --git a/internal/render/rtree.go b/internal/render/rtree.go
new file mode 100644
index 0000000..c53206b
--- /dev/null
+++ b/internal/render/rtree.go
@@ -0,0 +1,179 @@
+package render
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"io/fs"
+	"os"
+	"path/filepath"
+	"unicode/utf8"
+
+	"github.com/tgckpg/flatgit/internal/config"
+)
+
+func fileExists(path string) bool {
+	_, err := os.Stat(path)
+	return err == nil
+}
+
+func linkOrCopy(src, dst string, mode fs.FileMode) error {
+	if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
+		return err
+	}
+
+	// If this is a re-render into a fresh `next` dir this usually won't exist,
+	// but removing makes the helper safe for reuse.
+	if err := os.Remove(dst); err != nil && !errors.Is(err, os.ErrNotExist) {
+		return err
+	}
+
+	if err := os.Link(src, dst); err == nil {
+		return nil
+	}
+
+	in, err := os.Open(src)
+	if err != nil {
+		return err
+	}
+	defer in.Close()
+
+	out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
+	if err != nil {
+		return err
+	}
+
+	_, copyErr := io.Copy(out, in)
+	closeErr := out.Close()
+
+	if copyErr != nil {
+		return copyErr
+	}
+	return closeErr
+}
+
+func (r *Renderer) renderCommitFiles(
+	ctx context.Context,
+	repo config.Repo,
+	next string,
+	commit string,
+	revSlug string,
+	refLabel string,
+	page basePage,
+) error {
+	tree, err := r.tree(ctx, repo, commit)
+	if err != nil {
+		return err
+	}
+
+	if err := writeJSON(filepath.Join(next, "tree", revSlug, "tree.json"), tree); err != nil {
+		return err
+	}
+
+	if err := renderTemplate(filepath.Join(next, "tree", revSlug, "index.html"), treeTemplate, struct {
+		basePage
+		Ref   string
+		Files []TreeEntry
+	}{page, refLabel, tree}); err != nil {
+		return err
+	}
+
+	for _, e := range tree {
+		if e.Type != "blob" {
+			continue
+		}
+
+		if err := r.renderBlobEntry(ctx, repo, next, commit, revSlug, page, e); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func (r *Renderer) renderBlobEntry(
+	ctx context.Context,
+	repo config.Repo,
+	next string,
+	commit string,
+	revSlug string,
+	page basePage,
+	e TreeEntry,
+) error {
+	if e.Hash == "" {
+		return fmt.Errorf("tree entry %q has empty blob hash", e.Path)
+	}
+
+	blobID := e.Hash
+
+	rawObjectPath := filepath.Join(
+		next,
+		"_objects",
+		"raw",
+		blobID[:2],
+		blobID,
+	)
+
+	if !fileExists(rawObjectPath) {
+		content, err := r.Git.Output(
+			ctx,
+			repo.MirrorDir,
+			"cat-file",
+			"-p",
+			blobID,
+		)
+		if err != nil {
+			return fmt.Errorf("read blob %s for %s at %s: %w", blobID, e.Path, commit, err)
+		}
+
+		if err := writeFile(rawObjectPath, content, 0o644); err != nil {
+			return fmt.Errorf("write raw object %s: %w", rawObjectPath, err)
+		}
+	}
+
+	rawPath := filepath.Join(
+		next,
+		"raw",
+		revSlug,
+		filepath.FromSlash(e.Path),
+	)
+
+	if err := linkOrCopy(rawObjectPath, rawPath, 0o644); err != nil {
+		return fmt.Errorf("link/copy raw %s -> %s: %w", rawObjectPath, rawPath, err)
+	}
+
+	content, err := os.ReadFile(rawObjectPath)
+	if err != nil {
+		return fmt.Errorf("read raw object %s: %w", rawObjectPath, err)
+	}
+
+	blobPath := filepath.Join(
+		next,
+		"blob",
+		revSlug,
+		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 fmt.Errorf("render blob page %s: %w", blobPath, err)
+	}
+
+	return nil
+}
diff --git a/internal/render/templates.go b/internal/render/templates.go
index 1c8a414..3e7a6d9 100644
--- a/internal/render/templates.go
+++ b/internal/render/templates.go
@@ -21,7 +21,7 @@ const layoutTemplate = `{{define "layout"}}<!doctype html>
 <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}}tree/{{.Ref}}/index.html">files</a>
 <a href="{{.RepoManifest.Repository.SitePath}}manifest.json" title="express view for bots and agents">json</a>
 </nav>
 </header>
@@ -94,7 +94,7 @@ const treeTemplate = `{{define "pageTitle"}}{{.RepoManifest.Repository.FullName}
 <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>
+<tr><td class="mono">{{.Mode}}</td><td>{{.Type}}</td><td class="mono"><a href="{{$.RepoManifest.Repository.SitePath}}blob/{{$.Ref}}/{{.Path}}.html">{{.Path}}</a></td><td>{{.Size}}</td></tr>
 {{end}}
 </table>
 {{end}}
diff --git a/internal/server/server.go b/internal/server/server.go
index 16f9692..4aa8471 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -87,14 +87,53 @@ func ListenAndServe(ctx context.Context, opts Options) error {
 	}
 }
 
+func clientIP(r *http.Request) string {
+	if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
+		parts := strings.Split(xff, ",")
+		if len(parts) > 0 {
+			if ip := strings.TrimSpace(parts[0]); ip != "" {
+				return ip
+			}
+		}
+	}
+
+	if xrip := strings.TrimSpace(r.Header.Get("X-Real-IP")); xrip != "" {
+		return xrip
+	}
+
+	return ""
+}
+
 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))
+
+		args := []any{
+			"method", r.Method,
+			"path", r.URL.Path,
+			"remote", r.RemoteAddr,
+		}
+
+		if ip := clientIP(r); ip != "" {
+			args = append(args, "client_ip", ip)
+		}
+
+		if xff := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); xff != "" {
+			args = append(args, "x_forwarded_for", xff)
+		}
+
+		if xrip := strings.TrimSpace(r.Header.Get("X-Real-IP")); xrip != "" {
+			args = append(args, "x_real_ip", xrip)
+		}
+
+		args = append(args, "dur", time.Since(start))
+
+		log.Info("http request", args...)
 	})
 }
diff --git a/internal/webhook/webhook.go b/internal/webhook/webhook.go
index 0c6d468..a9a3687 100644
--- a/internal/webhook/webhook.go
+++ b/internal/webhook/webhook.go
@@ -44,9 +44,15 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	log := h.Logger
 
 	if log != nil {
+
+		xff := r.Header.Get("X-Forwarded-For")
+		xrealip := r.Header.Get("X-Real-IP")
+
 		log.Info("webhook request",
 			"method", r.Method,
 			"path", r.URL.Path,
+			"x_forwarded_for", xff,
+			"x_real_ip", xrealip,
 			"remote", r.RemoteAddr,
 			"event", r.Header.Get("X-Gitea-Event"),
 			"content_type", r.Header.Get("Content-Type"),