From 028f722e13a59fd804c9d38d8beb4d1d833cd980 Mon Sep 17 00:00:00 2001 From: Asim Aslam Date: Sun, 6 Sep 2026 06:06:57 +0100 Subject: [PATCH 1/9] server: add repository store abstraction --- server/storage.go | 55 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 server/storage.go diff --git a/server/storage.go b/server/storage.go new file mode 100644 index 0000000..528f4ca --- /dev/null +++ b/server/storage.go @@ -0,0 +1,55 @@ +package server + +import ( + "context" + "errors" + "os" + "path/filepath" +) + +var ErrRepositoryNotFound = errors.New("repository not found") + +type Repository interface { + Path() string +} + +type RepositoryStore interface { + Open(context.Context, string) (Repository, error) +} + +type filesystemRepository struct { + path string +} + +func (r filesystemRepository) Path() string { + return r.path +} + +type FilesystemStore struct { + Root string +} + +func NewFilesystemStore(root string) *FilesystemStore { + return &FilesystemStore{Root: root} +} + +func (s *FilesystemStore) Open(_ context.Context, name string) (Repository, error) { + root := s.Root + if root == "" { + cwd, err := os.Getwd() + if err != nil { + return nil, err + } + root = cwd + } + + p := filepath.Join(root, filepath.Clean("/"+name)) + if _, err := os.Stat(p); err != nil { + if os.IsNotExist(err) { + return nil, ErrRepositoryNotFound + } + return nil, err + } + + return filesystemRepository{path: p}, nil +} From aa47f572ec4d2093a4e02eb3661e3dfd90ccafcc Mon Sep 17 00:00:00 2001 From: Asim Aslam Date: Sun, 6 Sep 2026 06:07:26 +0100 Subject: [PATCH 2/9] server: make handler instance-based and storage-backed --- server/server.go | 321 ++++++++++++++++++++--------------------------- 1 file changed, 136 insertions(+), 185 deletions(-) diff --git a/server/server.go b/server/server.go index 6891bf6..4ad6ba2 100644 --- a/server/server.go +++ b/server/server.go @@ -3,6 +3,7 @@ package server import ( "compress/gzip" + "context" "fmt" "io" "log" @@ -18,7 +19,7 @@ import ( type Service struct { Method string - Handler func(HandlerReq) + Handler func(*Server, HandlerReq) Rpc string } @@ -43,6 +44,11 @@ type HandlerReq struct { File string } +type Server struct { + Config Config + Store RepositoryStore +} + var ( DefaultAddress = ":8080" @@ -60,6 +66,23 @@ var ( } ) +func New(config Config, store RepositoryStore) *Server { + if config.GitBinPath == "" { + config.GitBinPath = "/usr/bin/git" + } + if config.CommandFunc == nil { + config.CommandFunc = func(*exec.Cmd) {} + } + if store == nil { + store = NewFilesystemStore(config.ProjectRoot) + } + return &Server{Config: config, Store: store} +} + +func NewDefault() *Server { + return New(DefaultConfig, NewFilesystemStore(DefaultConfig.ProjectRoot)) +} + var services = map[string]Service{ "(.*?)/git-upload-pack$": Service{"POST", serviceRpc, "upload-pack"}, "(.*?)/git-receive-pack$": Service{"POST", serviceRpc, "receive-pack"}, @@ -74,48 +97,45 @@ var services = map[string]Service{ "(.*?)/objects/pack/pack-[0-9a-f]{40}\\.idx$": Service{"GET", getIdxFile, ""}, } -// Request handling function - func Handler() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - log.Printf("%s %s %s %s", r.RemoteAddr, r.Method, r.URL.Path, r.Proto) - for match, service := range services { - re, err := regexp.Compile(match) - if err != nil { - log.Print(err) - } - - if m := re.FindStringSubmatch(r.URL.Path); m != nil { - if service.Method != r.Method { - renderMethodNotAllowed(w, r) - return - } + return NewDefault().ServeHTTP +} - rpc := service.Rpc - file := strings.Replace(r.URL.Path, m[1]+"/", "", 1) - dir, err := getGitDir(m[1]) +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + log.Printf("%s %s %s %s", r.RemoteAddr, r.Method, r.URL.Path, r.Proto) + for match, service := range services { + re, err := regexp.Compile(s.Config.RoutePrefix + match) + if err != nil { + log.Print(err) + continue + } - if err != nil { - log.Print(err) - renderNotFound(w) - return - } + if m := re.FindStringSubmatch(r.URL.Path); m != nil { + if service.Method != r.Method { + renderMethodNotAllowed(w, r) + return + } - hr := HandlerReq{w, r, rpc, dir, file} - service.Handler(hr) + rpc := service.Rpc + file := strings.Replace(r.URL.Path, m[1]+"/", "", 1) + repo, err := s.Store.Open(r.Context(), strings.TrimPrefix(m[1], "/")) + if err != nil { + log.Print(err) + renderNotFound(w) return } + + hr := HandlerReq{w: w, r: r, Rpc: rpc, Dir: repo.Path(), File: file} + service.Handler(s, hr) + return } - renderNotFound(w) - return } + renderNotFound(w) } -// Actual command handling functions - -func serviceRpc(hr HandlerReq) { +func serviceRpc(s *Server, hr HandlerReq) { w, r, rpc, dir := hr.w, hr.r, hr.Rpc, hr.Dir - access := hasAccess(r, dir, rpc, true) + access := s.hasAccess(r, dir, rpc, true) if access == false { renderNoAccess(w) @@ -129,23 +149,22 @@ func serviceRpc(hr HandlerReq) { w.WriteHeader(http.StatusOK) env := os.Environ() - - if DefaultConfig.DefaultEnv != "" { - env = append(env, DefaultConfig.DefaultEnv) + if s.Config.DefaultEnv != "" { + env = append(env, s.Config.DefaultEnv) } user, password, authok := r.BasicAuth() if authok { - if DefaultConfig.AuthUserEnvVar != "" { - env = append(env, fmt.Sprintf("%s=%s", DefaultConfig.AuthUserEnvVar, user)) + if s.Config.AuthUserEnvVar != "" { + env = append(env, fmt.Sprintf("%s=%s", s.Config.AuthUserEnvVar, user)) } - if DefaultConfig.AuthPassEnvVar != "" { - env = append(env, fmt.Sprintf("%s=%s", DefaultConfig.AuthPassEnvVar, password)) + if s.Config.AuthPassEnvVar != "" { + env = append(env, fmt.Sprintf("%s=%s", s.Config.AuthPassEnvVar, password)) } } args := []string{rpc, "--stateless-rpc", dir} - cmd := exec.Command(DefaultConfig.GitBinPath, args...) + cmd := exec.CommandContext(r.Context(), s.Config.GitBinPath, args...) version := r.Header.Get("Git-Protocol") cmd.Dir = dir @@ -154,233 +173,182 @@ func serviceRpc(hr HandlerReq) { cmd.Env = append(env, fmt.Sprintf("GIT_PROTOCOL=%s", version)) } - DefaultConfig.CommandFunc(cmd) + s.Config.CommandFunc(cmd) in, err := cmd.StdinPipe() if err != nil { log.Print(err) + return } - stdout, err := cmd.StdoutPipe() if err != nil { log.Print(err) + return } - - err = cmd.Start() - if err != nil { + if err = cmd.Start(); err != nil { log.Print(err) + return } var reader io.ReadCloser switch r.Header.Get("Content-Encoding") { case "gzip": reader, err = gzip.NewReader(r.Body) + if err != nil { + log.Print(err) + return + } defer reader.Close() default: reader = r.Body } - io.Copy(in, reader) - in.Close() + _, _ = io.Copy(in, reader) + _ = in.Close() flusher, ok := w.(http.Flusher) if !ok { - panic("expected http.ResponseWriter to be an http.Flusher") + log.Print("response writer does not support flushing") + return } - p := make([]byte, 1024) + p := make([]byte, 32*1024) for { - n_read, err := stdout.Read(p) - if err == io.EOF { - break + nRead, readErr := stdout.Read(p) + if nRead > 0 { + if _, writeErr := w.Write(p[:nRead]); writeErr != nil { + log.Print(writeErr) + return + } + flusher.Flush() } - n_write, err := w.Write(p[:n_read]) - if err != nil { - fmt.Println(err) - os.Exit(1) + if readErr == io.EOF { + break } - if n_read != n_write { - fmt.Printf("failed to write data: %d read, %d written\n", n_read, n_write) - os.Exit(1) + if readErr != nil { + log.Print(readErr) + break } - flusher.Flush() } - cmd.Wait() + if err := cmd.Wait(); err != nil { + log.Print(err) + } } -func getInfoRefs(hr HandlerReq) { +func getInfoRefs(s *Server, hr HandlerReq) { w, r, dir := hr.w, hr.r, hr.Dir - service_name := getServiceType(r) - access := hasAccess(r, dir, service_name, false) + serviceName := getServiceType(r) + access := s.hasAccess(r, dir, serviceName, false) version := r.Header.Get("Git-Protocol") - user, password, authok := r.BasicAuth() - if DefaultConfig.RequireAuth && !authok { + _, _, authok := r.BasicAuth() + if s.Config.RequireAuth && !authok { renderAuthRequire(w) return } - if authok && user != DefaultConfig.AuthUserEnvVar && password != DefaultConfig.AuthPassEnvVar { - w.WriteHeader(http.StatusUnauthorized) - return - } - if access { - args := []string{service_name, "--stateless-rpc", "--advertise-refs", "."} - refs := gitCommand(dir, version, args...) + args := []string{serviceName, "--stateless-rpc", "--advertise-refs", "."} + refs := s.gitCommand(context.Background(), dir, version, args...) hdrNocache(w) - w.Header().Set("Content-Type", fmt.Sprintf("application/x-git-%s-advertisement", service_name)) + w.Header().Set("Content-Type", fmt.Sprintf("application/x-git-%s-advertisement", serviceName)) w.WriteHeader(http.StatusOK) if len(version) == 0 { - w.Write(packetWrite("# service=git-" + service_name + "\n")) + w.Write(packetWrite("# service=git-" + serviceName + "\n")) w.Write(packetFlush()) } w.Write(refs) } else { - updateServerInfo(dir) + s.updateServerInfo(dir) hdrNocache(w) sendFile("text/plain; charset=utf-8", hr) } } -func getInfoPacks(hr HandlerReq) { - hdrCacheForever(hr.w) - sendFile("text/plain; charset=utf-8", hr) -} - -func getLooseObject(hr HandlerReq) { - hdrCacheForever(hr.w) - sendFile("application/x-git-loose-object", hr) -} - -func getPackFile(hr HandlerReq) { - hdrCacheForever(hr.w) - sendFile("application/x-git-packed-objects", hr) -} - -func getIdxFile(hr HandlerReq) { - hdrCacheForever(hr.w) - sendFile("application/x-git-packed-objects-toc", hr) -} - -func getTextFile(hr HandlerReq) { - hdrNocache(hr.w) - sendFile("text/plain", hr) -} +func getInfoPacks(_ *Server, hr HandlerReq) { hdrCacheForever(hr.w); sendFile("text/plain; charset=utf-8", hr) } +func getLooseObject(_ *Server, hr HandlerReq) { hdrCacheForever(hr.w); sendFile("application/x-git-loose-object", hr) } +func getPackFile(_ *Server, hr HandlerReq) { hdrCacheForever(hr.w); sendFile("application/x-git-packed-objects", hr) } +func getIdxFile(_ *Server, hr HandlerReq) { hdrCacheForever(hr.w); sendFile("application/x-git-packed-objects-toc", hr) } +func getTextFile(_ *Server, hr HandlerReq) { hdrNocache(hr.w); sendFile("text/plain", hr) } -// Logic helping functions - -func sendFile(content_type string, hr HandlerReq) { +func sendFile(contentType string, hr HandlerReq) { w, r := hr.w, hr.r - req_file := path.Join(hr.Dir, hr.File) + reqFile := path.Join(hr.Dir, hr.File) - f, err := os.Stat(req_file) + f, err := os.Stat(reqFile) if os.IsNotExist(err) { renderNotFound(w) return } + if err != nil { + renderNotFound(w) + return + } - w.Header().Set("Content-Type", content_type) + w.Header().Set("Content-Type", contentType) w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size())) w.Header().Set("Last-Modified", f.ModTime().Format(http.TimeFormat)) - http.ServeFile(w, r, req_file) -} - -func getGitDir(file_path string) (string, error) { - root := DefaultConfig.ProjectRoot - - if root == "" { - cwd, err := os.Getwd() - - if err != nil { - log.Print(err) - return "", err - } - - root = cwd - } - - f := path.Join(root, file_path) - if _, err := os.Stat(f); os.IsNotExist(err) { - return "", err - } - - return f, nil + http.ServeFile(w, r, reqFile) } func getServiceType(r *http.Request) string { - service_type := r.FormValue("service") - - if s := strings.HasPrefix(service_type, "git-"); !s { + serviceType := r.FormValue("service") + if !strings.HasPrefix(serviceType, "git-") { return "" } - - return strings.Replace(service_type, "git-", "", 1) + return strings.Replace(serviceType, "git-", "", 1) } -func hasAccess(r *http.Request, dir string, rpc string, check_content_type bool) bool { - if check_content_type { - if r.Header.Get("Content-Type") != fmt.Sprintf("application/x-git-%s-request", rpc) { - return false - } +func (s *Server) hasAccess(r *http.Request, dir string, rpc string, checkContentType bool) bool { + if checkContentType && r.Header.Get("Content-Type") != fmt.Sprintf("application/x-git-%s-request", rpc) { + return false } - if !(rpc == "upload-pack" || rpc == "receive-pack") { return false } if rpc == "receive-pack" { - return DefaultConfig.ReceivePack + return s.Config.ReceivePack } if rpc == "upload-pack" { - return DefaultConfig.UploadPack + return s.Config.UploadPack } - - return getConfigSetting(rpc, dir) + return s.getConfigSetting(rpc, dir) } -func getConfigSetting(service_name string, dir string) bool { - service_name = strings.Replace(service_name, "-", "", -1) - setting := getGitConfig("http."+service_name, dir) - - if service_name == "uploadpack" { +func (s *Server) getConfigSetting(serviceName string, dir string) bool { + serviceName = strings.Replace(serviceName, "-", "", -1) + setting := s.getGitConfig("http."+serviceName, dir) + if serviceName == "uploadpack" { return setting != "false" } - return setting == "true" } -func getGitConfig(config_name string, dir string) string { - args := []string{"config", config_name} - out := string(gitCommand(dir, "", args...)) - return out[0 : len(out)-1] +func (s *Server) getGitConfig(configName string, dir string) string { + out := string(s.gitCommand(context.Background(), dir, "", "config", configName)) + return strings.TrimSpace(out) } -func updateServerInfo(dir string) []byte { - args := []string{"update-server-info"} - return gitCommand(dir, "", args...) +func (s *Server) updateServerInfo(dir string) []byte { + return s.gitCommand(context.Background(), dir, "", "update-server-info") } -func gitCommand(dir string, version string, args ...string) []byte { - command := exec.Command(DefaultConfig.GitBinPath, args...) +func (s *Server) gitCommand(ctx context.Context, dir string, version string, args ...string) []byte { + command := exec.CommandContext(ctx, s.Config.GitBinPath, args...) if len(version) > 0 { command.Env = append(os.Environ(), fmt.Sprintf("GIT_PROTOCOL=%s", version)) } command.Dir = dir - - DefaultConfig.CommandFunc(command) + s.Config.CommandFunc(command) out, err := command.Output() - if err != nil { log.Print(err) } - return out } -// HTTP error response handling functions - func renderMethodNotAllowed(w http.ResponseWriter, r *http.Request) { if r.Proto == "HTTP/1.1" { w.WriteHeader(http.StatusMethodNotAllowed) @@ -391,16 +359,8 @@ func renderMethodNotAllowed(w http.ResponseWriter, r *http.Request) { } } -func renderNotFound(w http.ResponseWriter) { - w.WriteHeader(http.StatusNotFound) - w.Write([]byte("Not Found")) -} - -func renderNoAccess(w http.ResponseWriter) { - w.WriteHeader(http.StatusForbidden) - w.Write([]byte("Forbidden")) -} - +func renderNotFound(w http.ResponseWriter) { w.WriteHeader(http.StatusNotFound); w.Write([]byte("Not Found")) } +func renderNoAccess(w http.ResponseWriter) { w.WriteHeader(http.StatusForbidden); w.Write([]byte("Forbidden")) } func renderAuthRequire(w http.ResponseWriter) { w.Header().Add("Content-Type", "text/plain") w.Header().Add("WWW-Authenticate", "Basic realm=\"authorization needed\"") @@ -408,24 +368,15 @@ func renderAuthRequire(w http.ResponseWriter) { w.Write([]byte("401 Unauthorized")) } -// Packet-line handling function - -func packetFlush() []byte { - return []byte("0000") -} - +func packetFlush() []byte { return []byte("0000") } func packetWrite(str string) []byte { s := strconv.FormatInt(int64(len(str)+4), 16) - if len(s)%4 != 0 { s = strings.Repeat("0", 4-len(s)%4) + s } - return []byte(s + str) } -// Header writing functions - func hdrNocache(w http.ResponseWriter) { w.Header().Set("Expires", "Fri, 01 Jan 1980 00:00:00 GMT") w.Header().Set("Pragma", "no-cache") From 52fce605ae2d5044faa51db7b9cab65c81433764 Mon Sep 17 00:00:00 2001 From: Asim Aslam Date: Sun, 6 Sep 2026 06:07:31 +0100 Subject: [PATCH 3/9] server: test filesystem repository store --- server/storage_test.go | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 server/storage_test.go diff --git a/server/storage_test.go b/server/storage_test.go new file mode 100644 index 0000000..04acf5a --- /dev/null +++ b/server/storage_test.go @@ -0,0 +1,34 @@ +package server + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" +) + +func TestFilesystemStoreOpen(t *testing.T) { + root := t.TempDir() + repoPath := filepath.Join(root, "example.git") + if err := os.Mkdir(repoPath, 0755); err != nil { + t.Fatal(err) + } + + store := NewFilesystemStore(root) + repo, err := store.Open(context.Background(), "example.git") + if err != nil { + t.Fatal(err) + } + if repo.Path() != repoPath { + t.Fatalf("expected %q, got %q", repoPath, repo.Path()) + } +} + +func TestFilesystemStoreNotFound(t *testing.T) { + store := NewFilesystemStore(t.TempDir()) + _, err := store.Open(context.Background(), "missing.git") + if !errors.Is(err, ErrRepositoryNotFound) { + t.Fatalf("expected ErrRepositoryNotFound, got %v", err) + } +} From 59305a8f7cf0208989482d05852a651e53a27a0a Mon Sep 17 00:00:00 2001 From: Asim Aslam Date: Sun, 6 Sep 2026 06:15:34 +0100 Subject: [PATCH 4/9] Rename RepositoryStore to Store --- server/storage.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/storage.go b/server/storage.go index 528f4ca..395bc52 100644 --- a/server/storage.go +++ b/server/storage.go @@ -13,7 +13,7 @@ type Repository interface { Path() string } -type RepositoryStore interface { +type Store interface { Open(context.Context, string) (Repository, error) } From a06cbe2d92d27f6512025b549a002f38bb069095 Mon Sep 17 00:00:00 2001 From: Asim Aslam Date: Sun, 6 Sep 2026 06:16:00 +0100 Subject: [PATCH 5/9] Use Store interface name --- server/server.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/server.go b/server/server.go index 4ad6ba2..0f08c73 100644 --- a/server/server.go +++ b/server/server.go @@ -46,7 +46,7 @@ type HandlerReq struct { type Server struct { Config Config - Store RepositoryStore + Store Store } var ( @@ -66,7 +66,7 @@ var ( } ) -func New(config Config, store RepositoryStore) *Server { +func New(config Config, store Store) *Server { if config.GitBinPath == "" { config.GitBinPath = "/usr/bin/git" } From 5db075cb73ee2240e5b1bd0f460aa5a64db55406 Mon Sep 17 00:00:00 2001 From: Asim Aslam Date: Sun, 6 Sep 2026 07:41:44 +0100 Subject: [PATCH 6/9] Update Go CI workflow --- .github/workflows/go.yml | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index b66c0d7..c6218cf 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -7,28 +7,20 @@ on: branches: [ master ] jobs: - build: name: Build runs-on: ubuntu-latest steps: + - name: Check out code + uses: actions/checkout@v4 - - name: Set up Go 1.13 - uses: actions/setup-go@v1 - with: - go-version: 1.13 - id: go - - - name: Check out code into the Go module directory - uses: actions/checkout@v2 + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod - - name: Get dependencies - run: | - go get -v -t -d ./... - if [ -f Gopkg.toml ]; then - curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh - dep ensure - fi + - name: Test + run: go test ./... - - name: Build - run: go build -v . + - name: Build + run: go build -v . From 616e8c30aecdce7dde9cb54cd2f8de319308e6f0 Mon Sep 17 00:00:00 2001 From: Asim Aslam Date: Sun, 6 Sep 2026 07:42:29 +0100 Subject: [PATCH 7/9] Fix server routing, store errors, and auth --- server/server.go | 65 ++++++++++++++++++++++++++++++++---------------- 1 file changed, 43 insertions(+), 22 deletions(-) diff --git a/server/server.go b/server/server.go index 0f08c73..3907059 100644 --- a/server/server.go +++ b/server/server.go @@ -4,6 +4,7 @@ package server import ( "compress/gzip" "context" + "errors" "fmt" "io" "log" @@ -110,25 +111,38 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { continue } - if m := re.FindStringSubmatch(r.URL.Path); m != nil { - if service.Method != r.Method { - renderMethodNotAllowed(w, r) - return - } + indexes := re.FindStringSubmatchIndex(r.URL.Path) + if indexes == nil { + continue + } - rpc := service.Rpc - file := strings.Replace(r.URL.Path, m[1]+"/", "", 1) - repo, err := s.Store.Open(r.Context(), strings.TrimPrefix(m[1], "/")) - if err != nil { - log.Print(err) + if service.Method != r.Method { + renderMethodNotAllowed(w, r) + return + } + + matches := re.FindStringSubmatch(r.URL.Path) + rpc := service.Rpc + repoName := strings.TrimPrefix(matches[1], "/") + file := "" + if len(indexes) >= 4 && indexes[3] >= 0 && indexes[3] <= len(r.URL.Path) { + file = strings.TrimPrefix(r.URL.Path[indexes[3]:], "/") + } + + repo, err := s.Store.Open(r.Context(), repoName) + if err != nil { + log.Print(err) + if errors.Is(err, ErrRepositoryNotFound) { renderNotFound(w) - return + } else { + renderInternalServerError(w) } - - hr := HandlerReq{w: w, r: r, Rpc: rpc, Dir: repo.Path(), File: file} - service.Handler(s, hr) return } + + hr := HandlerReq{w: w, r: r, Rpc: rpc, Dir: repo.Path(), File: file} + service.Handler(s, hr) + return } renderNotFound(w) } @@ -241,15 +255,21 @@ func getInfoRefs(s *Server, hr HandlerReq) { access := s.hasAccess(r, dir, serviceName, false) version := r.Header.Get("Git-Protocol") - _, _, authok := r.BasicAuth() - if s.Config.RequireAuth && !authok { - renderAuthRequire(w) - return + user, password, authok := r.BasicAuth() + if s.Config.RequireAuth { + if !authok { + renderAuthRequire(w) + return + } + if user != s.Config.AuthUserEnvVar || password != s.Config.AuthPassEnvVar { + renderAuthRequire(w) + return + } } if access { args := []string{serviceName, "--stateless-rpc", "--advertise-refs", "."} - refs := s.gitCommand(context.Background(), dir, version, args...) + refs := s.gitCommand(r.Context(), dir, version, args...) hdrNocache(w) w.Header().Set("Content-Type", fmt.Sprintf("application/x-git-%s-advertisement", serviceName)) @@ -260,7 +280,7 @@ func getInfoRefs(s *Server, hr HandlerReq) { } w.Write(refs) } else { - s.updateServerInfo(dir) + s.updateServerInfo(r.Context(), dir) hdrNocache(w) sendFile("text/plain; charset=utf-8", hr) } @@ -330,8 +350,8 @@ func (s *Server) getGitConfig(configName string, dir string) string { return strings.TrimSpace(out) } -func (s *Server) updateServerInfo(dir string) []byte { - return s.gitCommand(context.Background(), dir, "", "update-server-info") +func (s *Server) updateServerInfo(ctx context.Context, dir string) []byte { + return s.gitCommand(ctx, dir, "", "update-server-info") } func (s *Server) gitCommand(ctx context.Context, dir string, version string, args ...string) []byte { @@ -360,6 +380,7 @@ func renderMethodNotAllowed(w http.ResponseWriter, r *http.Request) { } func renderNotFound(w http.ResponseWriter) { w.WriteHeader(http.StatusNotFound); w.Write([]byte("Not Found")) } +func renderInternalServerError(w http.ResponseWriter) { w.WriteHeader(http.StatusInternalServerError); w.Write([]byte("Internal Server Error")) } func renderNoAccess(w http.ResponseWriter) { w.WriteHeader(http.StatusForbidden); w.Write([]byte("Forbidden")) } func renderAuthRequire(w http.ResponseWriter) { w.Header().Add("Content-Type", "text/plain") From a3ce539149f84977149c0116a93691a03b3e4cd5 Mon Sep 17 00:00:00 2001 From: Asim Aslam Date: Sun, 6 Sep 2026 07:42:45 +0100 Subject: [PATCH 8/9] Add server regression tests --- server/server_test.go | 93 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 server/server_test.go diff --git a/server/server_test.go b/server/server_test.go new file mode 100644 index 0000000..3c9138a --- /dev/null +++ b/server/server_test.go @@ -0,0 +1,93 @@ +package server + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +type testRepository string + +func (r testRepository) Path() string { return string(r) } + +type testStore struct { + repo Repository + err error + name string +} + +func (s *testStore) Open(_ context.Context, name string) (Repository, error) { + s.name = name + return s.repo, s.err +} + +func TestServerRoutePrefixStaticFile(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "HEAD"), []byte("ref: refs/heads/main\n"), 0644); err != nil { + t.Fatal(err) + } + + store := &testStore{repo: testRepository(dir)} + srv := New(Config{RoutePrefix: "/git"}, store) + req := httptest.NewRequest(http.MethodGet, "/git/example.git/HEAD", nil) + res := httptest.NewRecorder() + + srv.ServeHTTP(res, req) + + if res.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", res.Code, res.Body.String()) + } + if store.name != "example.git" { + t.Fatalf("expected repository example.git, got %q", store.name) + } + if res.Body.String() != "ref: refs/heads/main\n" { + t.Fatalf("unexpected response body %q", res.Body.String()) + } +} + +func TestServerStoreNotFound(t *testing.T) { + srv := New(Config{}, &testStore{err: ErrRepositoryNotFound}) + req := httptest.NewRequest(http.MethodGet, "/missing.git/HEAD", nil) + res := httptest.NewRecorder() + + srv.ServeHTTP(res, req) + + if res.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", res.Code) + } +} + +func TestServerStoreError(t *testing.T) { + srv := New(Config{}, &testStore{err: errors.New("storage unavailable")}) + req := httptest.NewRequest(http.MethodGet, "/example.git/HEAD", nil) + res := httptest.NewRecorder() + + srv.ServeHTTP(res, req) + + if res.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", res.Code) + } +} + +func TestServerRequireAuthRejectsWrongCredentials(t *testing.T) { + store := &testStore{repo: testRepository(t.TempDir())} + srv := New(Config{ + RequireAuth: true, + AuthUserEnvVar: "user", + AuthPassEnvVar: "pass", + UploadPack: true, + }, store) + req := httptest.NewRequest(http.MethodGet, "/example.git/info/refs?service=git-upload-pack", nil) + req.SetBasicAuth("wrong", "credentials") + res := httptest.NewRecorder() + + srv.ServeHTTP(res, req) + + if res.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", res.Code) + } +} From a803efd10ada68dc04103af082a00529f6ffd56d Mon Sep 17 00:00:00 2001 From: Asim Aslam Date: Sun, 6 Sep 2026 07:43:03 +0100 Subject: [PATCH 9/9] Fix GoReleaser pull request workflow --- .github/workflows/release.yml | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b18f9ee..6fc9ead 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,14 +4,11 @@ name: goreleaser on: pull_request: push: - # run only against tags tags: - "*" permissions: contents: write - # packages: write - # issues: write jobs: goreleaser: @@ -21,21 +18,26 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Run GoReleaser snapshot + if: github.event_name == 'pull_request' + uses: goreleaser/goreleaser-action@v5 with: - go-version: stable - # More assembly might be required: Docker logins, GPG, etc. - # It all depends on your needs. + distribution: goreleaser + version: "~> v1" + args: release --snapshot --clean + - name: Run GoReleaser + if: github.event_name != 'pull_request' uses: goreleaser/goreleaser-action@v5 with: - # either 'goreleaser' (default) or 'goreleaser-pro' distribution: goreleaser - # 'latest', 'nightly', or a semver version: "~> v1" args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Your GoReleaser Pro key, if you are using the 'goreleaser-pro' distribution - # GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}