penguin/flatgit

flatgit showcase

commit 249de61fd9ac8f7c418b6a45f54f28ddfb220173

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-10-05T04:21:09Z
subjectAdded git clone support
commit 249de61fd9ac8f7c418b6a45f54f28ddfb220173
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-10-05T04:21:09Z

    Added git clone support
---
 cmd/flatgit/main.go                 |   2 +
 internal/buildinfo/buildinfo_gen.go |   4 +-
 internal/server/git_http.go         | 189 ++++++++++++++++++++++++++++++++++++
 internal/server/server.go           |  21 +++-
 4 files changed, 213 insertions(+), 3 deletions(-)

diff --git a/cmd/flatgit/main.go b/cmd/flatgit/main.go
index b290789..48838ec 100644
--- a/cmd/flatgit/main.go
+++ b/cmd/flatgit/main.go
@@ -163,6 +163,8 @@ func daemonCmd(log *slog.Logger, level *slog.LevelVar, args []string) error {
 		WebhookMux: func(mux *http.ServeMux) {
 			wh.Register(mux)
 		},
+		GitCommand: cfg.Git.Command,
+		Repos:      cfg.Repos,
 	})
 }
 
diff --git a/internal/buildinfo/buildinfo_gen.go b/internal/buildinfo/buildinfo_gen.go
index c7fcd79..be9bc49 100644
--- a/internal/buildinfo/buildinfo_gen.go
+++ b/internal/buildinfo/buildinfo_gen.go
@@ -2,6 +2,6 @@ package buildinfo
 
 const (
     Version     = "dev"
-    GitRevision = "b4e8406c82460f7b26dbf885ff5c6bf9a0b532f5"
-    Timestamp   = "20260704.125746"
+    GitRevision = "351aa29cde84023a850bad9d3ede7f44a18b85e3"
+    Timestamp   = "20261005.041719"
 )
diff --git a/internal/server/git_http.go b/internal/server/git_http.go
new file mode 100644
index 0000000..2900b85
--- /dev/null
+++ b/internal/server/git_http.go
@@ -0,0 +1,189 @@
+package server
+
+import (
+	"bytes"
+	"log/slog"
+	"net/http"
+	"os"
+	"os/exec"
+	"strings"
+
+	"github.com/tgckpg/flatgit/internal/config"
+)
+
+type gitHTTP struct {
+	gitCommand string
+	logger     *slog.Logger
+
+	// Exact HTTP repository base path -> bare mirror directory.
+	//
+	// Example:
+	//   /penguin/flatgit -> /var/lib/flatgit/repos/...
+	repos map[string]string
+}
+
+func newGitHTTP(
+	gitCommand string,
+	repos []config.Repo,
+	logger *slog.Logger,
+) *gitHTTP {
+	if gitCommand == "" {
+		gitCommand = "git"
+	}
+
+	g := &gitHTTP{
+		gitCommand: gitCommand,
+		logger:     logger,
+		repos:      make(map[string]string, len(repos)*2),
+	}
+
+	for _, repo := range repos {
+		base := strings.TrimSuffix(repo.RepoBase(), "/")
+
+		g.repos[base] = repo.MirrorDir
+
+		// Supporting the conventional .git form is basically free.
+		g.repos[base+".git"] = repo.MirrorDir
+	}
+
+	return g
+}
+
+// ServeHTTP handles only the two endpoints needed for read-only Smart HTTP.
+//
+// Returns true if the request belonged to the Git transport.
+// Everything else should fall through to Flatgit's static server.
+func (g *gitHTTP) ServeHTTP(w http.ResponseWriter, r *http.Request) bool {
+	path := strings.TrimSuffix(r.URL.Path, "/")
+
+	/*
+		GET /owner/repo/info/refs?service=git-upload-pack
+	*/
+	if r.Method == http.MethodGet &&
+		strings.HasSuffix(path, "/info/refs") {
+
+		base := strings.TrimSuffix(path, "/info/refs")
+		mirror, ok := g.repos[base]
+		if !ok {
+			return false
+		}
+
+		if r.URL.Query().Get("service") != "git-upload-pack" {
+			http.Error(w, "unsupported git service", http.StatusForbidden)
+			return true
+		}
+
+		g.infoRefs(w, r, mirror)
+		return true
+	}
+
+	/*
+		POST /owner/repo/git-upload-pack
+	*/
+	if r.Method == http.MethodPost &&
+		strings.HasSuffix(path, "/git-upload-pack") {
+
+		base := strings.TrimSuffix(path, "/git-upload-pack")
+		mirror, ok := g.repos[base]
+		if !ok {
+			return false
+		}
+
+		g.uploadPack(w, r, mirror)
+		return true
+	}
+
+	return false
+}
+
+func (g *gitHTTP) command(r *http.Request, args ...string) *exec.Cmd {
+	cmd := exec.CommandContext(r.Context(), g.gitCommand, args...)
+
+	cmd.Env = append(
+		os.Environ(),
+
+		// upload-pack is read-only. Don't let Git perform optional
+		// repository maintenance or locking as a side effect.
+		"GIT_OPTIONAL_LOCKS=0",
+	)
+
+	// Git HTTP protocol negotiation, particularly protocol v2.
+	if protocol := r.Header.Get("Git-Protocol"); protocol != "" {
+		cmd.Env = append(cmd.Env, "GIT_PROTOCOL="+protocol)
+	}
+
+	return cmd
+}
+
+func (g *gitHTTP) infoRefs(
+	w http.ResponseWriter,
+	r *http.Request,
+	mirror string,
+) {
+	cmd := g.command(
+		r,
+		"upload-pack",
+		"--strict",
+		"--http-backend-info-refs",
+		mirror,
+	)
+
+	var stderr bytes.Buffer
+
+	cmd.Stdout = w
+	cmd.Stderr = &stderr
+
+	w.Header().Set(
+		"Content-Type",
+		"application/x-git-upload-pack-advertisement",
+	)
+	w.Header().Set("Cache-Control", "no-cache")
+	w.Header().Set("Pragma", "no-cache")
+	w.Header().Set("Expires", "Fri, 01 Jan 1980 00:00:00 GMT")
+
+	if err := cmd.Run(); err != nil {
+		g.logger.Error(
+			"git upload-pack advertisement failed",
+			"repo", mirror,
+			"error", err,
+			"stderr", strings.TrimSpace(stderr.String()),
+		)
+	}
+}
+
+func (g *gitHTTP) uploadPack(
+	w http.ResponseWriter,
+	r *http.Request,
+	mirror string,
+) {
+	cmd := g.command(
+		r,
+		"upload-pack",
+		"--strict",
+		"--stateless-rpc",
+		mirror,
+	)
+
+	var stderr bytes.Buffer
+
+	cmd.Stdin = r.Body
+	cmd.Stdout = w
+	cmd.Stderr = &stderr
+
+	w.Header().Set(
+		"Content-Type",
+		"application/x-git-upload-pack-result",
+	)
+	w.Header().Set("Cache-Control", "no-cache")
+	w.Header().Set("Pragma", "no-cache")
+	w.Header().Set("Expires", "Fri, 01 Jan 1980 00:00:00 GMT")
+
+	if err := cmd.Run(); err != nil {
+		g.logger.Error(
+			"git upload-pack failed",
+			"repo", mirror,
+			"error", err,
+			"stderr", strings.TrimSpace(stderr.String()),
+		)
+	}
+}
\ No newline at end of file
diff --git a/internal/server/server.go b/internal/server/server.go
index 4aa8471..8ff2371 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -8,6 +8,8 @@ import (
 	"path"
 	"strings"
 	"time"
+
+	"github.com/tgckpg/flatgit/internal/config"
 )
 
 type Options struct {
@@ -15,6 +17,9 @@ type Options struct {
 	Root       string
 	Logger     *slog.Logger
 	WebhookMux func(*http.ServeMux)
+
+	GitCommand string
+	Repos      []config.Repo
 }
 
 func withCacheHeaders(next http.Handler) http.Handler {
@@ -53,7 +58,21 @@ func ListenAndServe(ctx context.Context, opts Options) error {
 		opts.WebhookMux(mux)
 	}
 	fs := http.FileServer(http.Dir(opts.Root))
-	mux.Handle("/", withCacheHeaders(fs))
+	static := withCacheHeaders(fs)
+
+	git := newGitHTTP(
+		opts.GitCommand,
+		opts.Repos,
+		opts.Logger,
+	)
+
+	mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if git.ServeHTTP(w, r) {
+			return
+		}
+
+		static.ServeHTTP(w, r)
+	}))
 
 	srv := &http.Server{
 		Addr:              opts.Addr,