gitmote

gitmote  ·  history  ·  branches

feat: read path — smart-HTTP clone/fetch via git http-backend

f5b9e77585db12a95b9d2e5f2e5baa30c5b5c1d3
Atanas Minev <atmin@protonmail.com> · 2026-07-02 17:31

diff --git a/cmd/gitmote/main.go b/cmd/gitmote/main.go
index cd6fccc..21a675d 100644
--- a/cmd/gitmote/main.go
+++ b/cmd/gitmote/main.go
@@ -10,8 +10,14 @@ import (
"net/http"
"os"
"os/signal"
+ "path/filepath"
"syscall"
"time"
+
+ "github.com/atmin/gitmote/internal/githttp"
+ "github.com/atmin/gitmote/internal/meta"
+ "github.com/atmin/gitmote/internal/repo"
+ "github.com/atmin/gitmote/internal/store"
)
// version is stamped at build time via -ldflags "-X main.version=...".
@@ -37,9 +43,19 @@ func run(ctx context.Context, logger *slog.Logger, addr string) error {
ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
defer stop()
+ gitHandler, closeMeta, err := buildGitHandler(ctx, logger)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err := closeMeta(); err != nil {
+ logger.Error("closing metadata", "error", err)
+ }
+ }()
+
srv := &http.Server{
Addr: addr,
- Handler: newHandler(),
+ Handler: newHandler(gitHandler),
}
errCh := make(chan error, 1)
@@ -66,8 +82,10 @@ func run(ctx context.Context, logger *slog.Logger, addr string) error {
return nil
}
-// newHandler returns the server's HTTP routes.
-func newHandler() http.Handler {
+// newHandler returns the server's HTTP routes. gitHandler, when non-nil, serves
+// the git smart-HTTP endpoints at the catch-all "/"; the exact health/version
+// routes stay more specific and win under http.ServeMux.
+func newHandler(gitHandler http.Handler) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintln(w, "ok")
@@ -75,9 +93,47 @@ func newHandler() http.Handler {
mux.HandleFunc("GET /version", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintln(w, version)
})
+ if gitHandler != nil {
+ mux.Handle("/", gitHandler)
+ }
return mux
}
+// buildGitHandler wires the git read path (task 05) when an object store is
+// configured (GITMOTE_S3_BUCKET). Without it, the server runs health/version
+// only — a dev convenience — and the returned close func is a no-op.
+//
+// Metadata lives at GITMOTE_DB (default ./gitmote.sqlite3); materialized repos
+// cache under GITMOTE_CACHE (default a temp dir). Litestream replication of the
+// metadata DB is wired in the deploy task (11); it is local-only here.
+func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, func() error, error) {
+ noop := func() error { return nil }
+
+ if os.Getenv("GITMOTE_S3_BUCKET") == "" {
+ logger.Warn("GITMOTE_S3_BUCKET unset; git endpoints disabled (health/version only)")
+ return nil, noop, nil
+ }
+
+ objs, err := store.NewS3FromEnv(ctx)
+ if err != nil {
+ return nil, noop, fmt.Errorf("object store: %w", err)
+ }
+
+ dbPath := envOr("GITMOTE_DB", "gitmote.sqlite3")
+ md, err := meta.Open(ctx, meta.Config{LocalPath: dbPath})
+ if err != nil {
+ return nil, noop, fmt.Errorf("metadata: %w", err)
+ }
+
+ cacheRoot := envOr("GITMOTE_CACHE", filepath.Join(os.TempDir(), "gitmote-repos"))
+ handler, err := githttp.New(repo.New(md, objs, cacheRoot), logger)
+ if err != nil {
+ _ = md.Close()
+ return nil, noop, err
+ }
+ return handler, md.Close, nil
+}
+
// envOr returns the value of the environment variable key, or fallback if
// it is unset or empty.
func envOr(key, fallback string) string {
diff --git a/cmd/gitmote/main_test.go b/cmd/gitmote/main_test.go
index 2a32306..d3a144d 100644
--- a/cmd/gitmote/main_test.go
+++ b/cmd/gitmote/main_test.go
@@ -11,7 +11,7 @@ import (
)
func TestHealthz(t *testing.T) {
- srv := httptest.NewServer(newHandler())
+ srv := httptest.NewServer(newHandler(nil))
defer srv.Close()
resp, err := http.Get(srv.URL + "/healthz")
@@ -26,7 +26,7 @@ func TestHealthz(t *testing.T) {
}
func TestVersion(t *testing.T) {
- srv := httptest.NewServer(newHandler())
+ srv := httptest.NewServer(newHandler(nil))
defer srv.Close()
resp, err := http.Get(srv.URL + "/version")
@@ -41,7 +41,7 @@ func TestVersion(t *testing.T) {
}
func TestUnknownRouteNotFound(t *testing.T) {
- srv := httptest.NewServer(newHandler())
+ srv := httptest.NewServer(newHandler(nil))
defer srv.Close()
resp, err := http.Get(srv.URL + "/nope")
@@ -55,6 +55,34 @@ func TestUnknownRouteNotFound(t *testing.T) {
}
}
+func TestGitHandlerMountedAtRoot(t *testing.T) {
+ // A provided git handler serves the catch-all "/" while the exact
+ // health/version routes stay more specific and win.
+ gitHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusTeapot)
+ })
+ srv := httptest.NewServer(newHandler(gitHandler))
+ defer srv.Close()
+
+ health, err := http.Get(srv.URL + "/healthz")
+ if err != nil {
+ t.Fatalf("GET /healthz: %v", err)
+ }
+ health.Body.Close()
+ if health.StatusCode != http.StatusOK {
+ t.Errorf("GET /healthz status = %d, want %d", health.StatusCode, http.StatusOK)
+ }
+
+ gitResp, err := http.Get(srv.URL + "/some/repo/info/refs")
+ if err != nil {
+ t.Fatalf("GET /some/repo/info/refs: %v", err)
+ }
+ gitResp.Body.Close()
+ if gitResp.StatusCode != http.StatusTeapot {
+ t.Errorf("git route status = %d, want %d (routed to git handler)", gitResp.StatusCode, http.StatusTeapot)
+ }
+}
+
func TestRunShutsDownOnCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
logger := slog.New(slog.DiscardHandler)
diff --git a/internal/githttp/backend.go b/internal/githttp/backend.go
new file mode 100644
index 0000000..3d66148
--- /dev/null
+++ b/internal/githttp/backend.go
@@ -0,0 +1,105 @@
+// Package githttp serves the git smart-HTTP read path (clone / fetch). It
+// materializes the target repo (refs from s3lite, objects hydrated from the
+// object store) and then delegates all protocol work — ref advertisement and
+// pack negotiation — to stock `git http-backend` over CGI, per the read path
+// in docs/architecture/request-flows.md. Only the upload-pack (read) service is
+// routed here; the write path (receive-pack) lands in task 07.
+package githttp
+
+import (
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "net/http/cgi"
+ "os/exec"
+ "strings"
+
+ "github.com/atmin/gitmote/internal/meta"
+ "github.com/atmin/gitmote/internal/repo"
+)
+
+// Handler serves the read-path smart-HTTP endpoints for any repo, dispatching
+// on the git URL suffix. Mount it at "/"; more specific routes (e.g. /healthz)
+// still win under http.ServeMux.
+type Handler struct {
+ mz *repo.Materializer
+ gitPath string
+ logger *slog.Logger
+}
+
+// New returns a read-path handler backed by mz. It fails if the `git`
+// executable is not on PATH, since the whole design delegates to it.
+func New(mz *repo.Materializer, logger *slog.Logger) (*Handler, error) {
+ gitPath, err := exec.LookPath("git")
+ if err != nil {
+ return nil, fmt.Errorf("githttp: git not found on PATH: %w", err)
+ }
+ if logger == nil {
+ logger = slog.New(slog.DiscardHandler)
+ }
+ return &Handler{mz: mz, gitPath: gitPath, logger: logger}, nil
+}
+
+func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ repoName, endpoint, ok := parseReadPath(r.URL.Path)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ // Serve only the upload-pack (read) service. receive-pack — and a
+ // receive-pack advertisement — are not part of the read path.
+ switch endpoint {
+ case "info/refs":
+ if r.Method != http.MethodGet || r.URL.Query().Get("service") != "git-upload-pack" {
+ http.NotFound(w, r)
+ return
+ }
+ case "git-upload-pack":
+ if r.Method != http.MethodPost {
+ http.NotFound(w, r)
+ return
+ }
+ }
+
+ if _, err := h.mz.Materialize(r.Context(), repoName); err != nil {
+ if errors.Is(err, meta.ErrNotFound) {
+ http.NotFound(w, r)
+ return
+ }
+ h.logger.Error("materialize failed", "repo", repoName, "error", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+
+ // Delegate to git-http-backend. PATH_INFO carries the full request path
+ // (empty CGI Root) and GIT_PROJECT_ROOT is the cache root, so http-backend
+ // resolves the repo dir the materializer just wrote.
+ backend := &cgi.Handler{
+ Path: h.gitPath,
+ Args: []string{"http-backend"},
+ Dir: h.mz.Root(),
+ Env: []string{
+ "GIT_PROJECT_ROOT=" + h.mz.Root(),
+ "GIT_HTTP_EXPORT_ALL=1",
+ },
+ }
+ backend.ServeHTTP(w, r)
+}
+
+// parseReadPath splits a git smart-HTTP read URL into the repo name and the
+// endpoint suffix ("info/refs" or "git-upload-pack"). The repo name — which may
+// contain slashes ("atmin/dotfiles") — must be non-empty.
+func parseReadPath(urlPath string) (repoName, endpoint string, ok bool) {
+ p := strings.TrimPrefix(urlPath, "/")
+ for _, ep := range []string{"info/refs", "git-upload-pack"} {
+ if suffix := "/" + ep; strings.HasSuffix(p, suffix) {
+ name := strings.TrimSuffix(p, suffix)
+ if name == "" {
+ return "", "", false
+ }
+ return name, ep, true
+ }
+ }
+ return "", "", false
+}
diff --git a/internal/githttp/backend_test.go b/internal/githttp/backend_test.go
new file mode 100644
index 0000000..b3a7c4c
--- /dev/null
+++ b/internal/githttp/backend_test.go
@@ -0,0 +1,186 @@
+package githttp
+
+import (
+ "bytes"
+ "context"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/atmin/gitmote/internal/meta"
+ "github.com/atmin/gitmote/internal/repo"
+ "github.com/atmin/gitmote/internal/store"
+)
+
+// git runs a git command hermetically and returns its trimmed combined output,
+// failing the test on a nonzero exit.
+func git(t *testing.T, dir string, args ...string) string {
+ t.Helper()
+ cmd := exec.CommandContext(context.Background(), "git", args...)
+ cmd.Dir = dir
+ cmd.Env = append(os.Environ(),
+ "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com",
+ "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com",
+ "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
+ "GIT_TERMINAL_PROMPT=0",
+ )
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
+ }
+ return strings.TrimSpace(string(out))
+}
+
+// loadObjects mirrors a source repo's loose/packed objects into the store under
+// the repo's prefix — the durable objects a read hydrates from.
+func loadObjects(t *testing.T, s store.Store, src, repoName string) {
+ t.Helper()
+ objRoot := filepath.Join(src, ".git", "objects")
+ err := filepath.WalkDir(objRoot, func(p string, d os.DirEntry, err error) error {
+ if err != nil || d.IsDir() {
+ return err
+ }
+ rel, err := filepath.Rel(objRoot, p)
+ if err != nil {
+ return err
+ }
+ data, err := os.ReadFile(p)
+ if err != nil {
+ return err
+ }
+ return s.Put(context.Background(), repoName+"/objects/"+filepath.ToSlash(rel), bytes.NewReader(data))
+ })
+ if err != nil {
+ t.Fatalf("load objects: %v", err)
+ }
+}
+
+// newServer wires meta + mem store + materializer behind a read-path handler on
+// an httptest server, returning the server and its backing pieces.
+func newServer(t *testing.T) (*httptest.Server, *meta.Metadata, store.Store) {
+ t.Helper()
+ m, err := meta.Open(context.Background(), meta.Config{
+ LocalPath: filepath.Join(t.TempDir(), "meta.sqlite3"),
+ })
+ if err != nil {
+ t.Fatalf("meta.Open: %v", err)
+ }
+ t.Cleanup(func() { _ = m.Close() })
+
+ s := store.NewMem()
+ h, err := New(repo.New(m, s, t.TempDir()), slog.New(slog.DiscardHandler))
+ if err != nil {
+ t.Fatalf("githttp.New: %v", err)
+ }
+ srv := httptest.NewServer(h)
+ t.Cleanup(srv.Close)
+ return srv, m, s
+}
+
+func TestCloneAndFetch(t *testing.T) {
+ ctx := context.Background()
+ srv, m, s := newServer(t)
+ const repoName = "atmin/dotfiles"
+
+ // Seed: a source repo with one commit → objects into the store, ref into
+ // meta.
+ src := t.TempDir()
+ git(t, src, "init", "-b", "main", ".")
+ if err := os.WriteFile(filepath.Join(src, "README.md"), []byte("hello\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ git(t, src, "add", "README.md")
+ git(t, src, "commit", "-m", "first")
+ loadObjects(t, s, src, repoName)
+
+ head1 := git(t, src, "rev-parse", "HEAD")
+ r, err := m.CreateRepo(ctx, repoName, "main")
+ if err != nil {
+ t.Fatalf("CreateRepo: %v", err)
+ }
+ if err := m.CASRef(ctx, r.ID, "refs/heads/main", meta.ZeroSHA, head1); err != nil {
+ t.Fatalf("CASRef: %v", err)
+ }
+
+ // Golden path: a real clone over HTTP.
+ dst := filepath.Join(t.TempDir(), "clone")
+ git(t, "", "clone", srv.URL+"/"+repoName, dst)
+
+ if got := git(t, dst, "rev-parse", "HEAD"); got != head1 {
+ t.Errorf("cloned HEAD = %q, want %q", got, head1)
+ }
+ if data, err := os.ReadFile(filepath.Join(dst, "README.md")); err != nil || string(data) != "hello\n" {
+ t.Errorf("cloned README = %q (err %v), want \"hello\\n\"", data, err)
+ }
+ git(t, dst, "fsck", "--strict") // fails the test on any corruption/missing object
+
+ // Seed an update: a second commit on the server side.
+ if err := os.WriteFile(filepath.Join(src, "README.md"), []byte("hello\nworld\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ git(t, src, "commit", "-am", "second")
+ loadObjects(t, s, src, repoName)
+ head2 := git(t, src, "rev-parse", "HEAD")
+ if err := m.CASRef(ctx, r.ID, "refs/heads/main", head1, head2); err != nil {
+ t.Fatalf("CASRef advance: %v", err)
+ }
+
+ // fetch pulls the new commit.
+ git(t, dst, "fetch", "origin")
+ if got := git(t, dst, "rev-parse", "origin/main"); got != head2 {
+ t.Errorf("origin/main after fetch = %q, want %q", got, head2)
+ }
+}
+
+func TestCloneUnknownRepoFails(t *testing.T) {
+ srv, _, _ := newServer(t)
+ // A clone of a repo with no metadata row must not succeed.
+ dst := filepath.Join(t.TempDir(), "clone")
+ cmd := exec.Command("git", "clone", srv.URL+"/no/such", dst)
+ cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
+ if out, err := cmd.CombinedOutput(); err == nil {
+ t.Fatalf("clone of unknown repo succeeded, want failure\n%s", out)
+ }
+}
+
+func TestReceivePackNotServed(t *testing.T) {
+ srv, _, _ := newServer(t)
+ // The write service advertisement is not part of the read path (task 07).
+ resp, err := http.Get(srv.URL + "/atmin/dotfiles/info/refs?service=git-receive-pack")
+ if err != nil {
+ t.Fatalf("GET: %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("receive-pack advertisement status = %d, want 404", resp.StatusCode)
+ }
+}
+
+func TestParseReadPath(t *testing.T) {
+ cases := []struct {
+ path string
+ wantRepo string
+ wantEndpoint string
+ wantOK bool
+ }{
+ {"/atmin/dotfiles/info/refs", "atmin/dotfiles", "info/refs", true},
+ {"/atmin/dotfiles/git-upload-pack", "atmin/dotfiles", "git-upload-pack", true},
+ {"/one/info/refs", "one", "info/refs", true},
+ {"/info/refs", "", "", false}, // no repo
+ {"/git-upload-pack", "", "", false}, // no repo
+ {"/atmin/dotfiles/HEAD", "", "", false}, // not a served endpoint
+ {"/", "", "", false},
+ }
+ for _, c := range cases {
+ repoName, endpoint, ok := parseReadPath(c.path)
+ if repoName != c.wantRepo || endpoint != c.wantEndpoint || ok != c.wantOK {
+ t.Errorf("parseReadPath(%q) = (%q, %q, %v), want (%q, %q, %v)",
+ c.path, repoName, endpoint, ok, c.wantRepo, c.wantEndpoint, c.wantOK)
+ }
+ }
+}
diff --git a/internal/repo/materialize.go b/internal/repo/materialize.go
index 8578fad..f4356a1 100644
--- a/internal/repo/materialize.go
+++ b/internal/repo/materialize.go
@@ -32,6 +32,10 @@ func New(m *meta.Metadata, s store.Store, root string) *Materializer {
return &Materializer{meta: m, store: s, root: root}
}
+// Root is the cache directory under which repos materialize; a repo named
+// "a/b" lands at Root()/a/b. It is the git-http-backend GIT_PROJECT_ROOT.
+func (mz *Materializer) Root() string { return mz.root }
+
// Materialize ensures a valid on-disk bare repo for name exists under the cache
// root and returns its path. It creates the repo on a cache miss (rebuild-on-
// miss), hydrates the object closure from the store, and writes refs from the
diff --git a/tasks/05-read-path-clone.md b/tasks/05-read-path-clone.md
deleted file mode 100644
index 000dcb2..0000000
--- a/tasks/05-read-path-clone.md
+++ /dev/null
@@ -1,29 +0,0 @@
-# 05 — Read path: clone / fetch
-
-Depends on: 04.
-
-## Spec
-
-Serve smart-HTTP `git-upload-pack` so `git clone` / `fetch` work, per the read
-path in [request-flows.md](../docs/architecture/request-flows.md). No lock; refs
-advertised from s3lite, object closure hydrated from S3.
-
-## Current
-
-Only `/healthz` and `/version` exist (task 01).
-
-## Change
-
-- Routes: `GET /{repo}/info/refs?service=git-upload-pack` (advertise from
- s3lite) and `POST /{repo}/git-upload-pack`.
-- Materialize (task 04), then delegate protocol work to stock git via
- `net/http/cgi` invoking `git http-backend` (arch: wrap `http-backend`). Set
- `GIT_PROJECT_ROOT` / `PATH_INFO` to the materialized repo.
-- Unauthenticated for now — auth lands in task 06.
-
-## Verify
-
-- Against a running server with a seeded repo, a real
- `git clone http://localhost/{repo}` succeeds and `git fsck` on the clone is
- clean; `git fetch` after a seeded update pulls the new commits.
-- Non-breaking: adds read-only endpoints; existing routes untouched.
diff --git a/tasks/README.md b/tasks/README.md
index 6994327..d723584 100644
--- a/tasks/README.md
+++ b/tasks/README.md
@@ -6,7 +6,6 @@ lands** (commits are the record — this list never becomes a changelog).
Ordered by dependency; each task lands in a non-breaking, tested state.
-- [05-read-path-clone.md](05-read-path-clone.md) — Smart-HTTP `upload-pack`; `git clone` works.
- [06-auth-pat-acl.md](06-auth-pat-acl.md) — Bearer PATs + per-repo ACL enforcement.
- [07-write-path-cas.md](07-write-path-cas.md) — `receive-pack` + pre-receive RPC + object PUT + ref CAS; `git push` works.
- [08-bootstrap.md](08-bootstrap.md) — First admin, token, repo from an empty bucket.