e78725f7895eed0ec0bf48b851fccb975f570f6d

Author
Ayman Bagabas <ayman.bagabas@gmail.com>
Committer
GitHub <noreply@github.com>
Date

Message

Smart HTTP Git transport & partial clones (#291) (#332)

* refactor: tidy up server git

use git services to implement handling git server commands
pass config to git as environment variables

* feat(git): enable partial clones

* feat(server): use smart http git backend

This implements the smart http git protocol which also supports
git-receive-pack service.

Diff

This diff is truncated to protect this page.

   1diff --git a/cmd/soft/root.go b/cmd/soft/root.go
   2index 273c9d6a3e7f54649bf1dab8927724cec5665567..f78c2260645f98a2bd979d70daf43bd0a759556a 100644
   3--- a/cmd/soft/root.go
   4+++ b/cmd/soft/root.go
   5@@ -54,6 +54,9 @@ func init() {
   6 func main() {
   7 	logger := NewDefaultLogger()
   8 
   9+	// Set global logger
  10+	log.SetDefault(logger)
  11+
  12 	// Set the max number of processes to the number of CPUs
  13 	// This is useful when running soft serve in a container
  14 	if _, err := maxprocs.Set(maxprocs.Logger(logger.Debugf)); err != nil {
  15diff --git a/git/repo.go b/git/repo.go
  16index 98b81b24c56cf8f4450b9e7c30baeb80bb538a83..8c0b87c43dc642c6b107f7ddbb37014861b187a8 100644
  17--- a/git/repo.go
  18+++ b/git/repo.go
  19@@ -200,13 +200,6 @@ func (r *Repository) CommitsByPage(ref *Reference, page, size int) (Commits, err
  20 	return commits, nil
  21 }
  22 
  23-// UpdateServerInfo updates the repository server info.
  24-func (r *Repository) UpdateServerInfo() error {
  25-	cmd := git.NewCommand("update-server-info")
  26-	_, err := cmd.RunInDir(r.Path)
  27-	return err
  28-}
  29-
  30 // Config returns the config value for the given key.
  31 func (r *Repository) Config(key string, opts ...ConfigOptions) (string, error) {
  32 	dir, err := gitDir(r.Repository)
  33diff --git a/git/server.go b/git/server.go
  34new file mode 100644
  35index 0000000000000000000000000000000000000000..e868b1a29fd018edaeb54c88c84c7dd8655ef9c9
  36--- /dev/null
  37+++ b/git/server.go
  38@@ -0,0 +1,18 @@
  39+package git
  40+
  41+import (
  42+	"context"
  43+
  44+	"github.com/gogs/git-module"
  45+)
  46+
  47+// UpdateServerInfo updates the server info file for the given repo path.
  48+func UpdateServerInfo(ctx context.Context, path string) error {
  49+	if !isGitDir(path) {
  50+		return ErrNotAGitRepository
  51+	}
  52+
  53+	cmd := git.NewCommand("update-server-info").WithContext(ctx).WithTimeout(-1)
  54+	_, err := cmd.RunInDir(path)
  55+	return err
  56+}
  57diff --git a/git/utils.go b/git/utils.go
  58index 2b3d287285f8abf0a80a2c3084f2e97a246ce6ab..3710e172de7772b6c28c2a7e2f6806343c940028 100644
  59--- a/git/utils.go
  60+++ b/git/utils.go
  61@@ -1,6 +1,7 @@
  62 package git
  63 
  64 import (
  65+	"os"
  66 	"path/filepath"
  67 
  68 	"github.com/gobwas/glob"
  69@@ -49,3 +50,25 @@ func LatestFile(repo *Repository, pattern string) (string, string, error) {
  70 	}
  71 	return "", "", ErrFileNotFound
  72 }
  73+
  74+// Returns true if path is a directory containing an `objects` directory and a
  75+// `HEAD` file.
  76+func isGitDir(path string) bool {
  77+	stat, err := os.Stat(filepath.Join(path, "objects"))
  78+	if err != nil {
  79+		return false
  80+	}
  81+	if !stat.IsDir() {
  82+		return false
  83+	}
  84+
  85+	stat, err = os.Stat(filepath.Join(path, "HEAD"))
  86+	if err != nil {
  87+		return false
  88+	}
  89+	if stat.IsDir() {
  90+		return false
  91+	}
  92+
  93+	return true
  94+}
  95diff --git a/internal/log/log.go b/internal/log/log.go
  96index 7389b80fb9e073174653271fe505282369df91b5..b6c4b1443d19a5bda654a22428efb1fb70cbe16d 100644
  97--- a/internal/log/log.go
  98+++ b/internal/log/log.go
  99@@ -32,6 +32,10 @@ func NewDefaultLogger() *log.Logger {
 100 
 101 	if debug, _ := strconv.ParseBool(os.Getenv("SOFT_SERVE_DEBUG")); debug {
 102 		logger.SetLevel(log.DebugLevel)
 103+
 104+		if verbose, _ := strconv.ParseBool(os.Getenv("SOFT_SERVE_VERBOSE")); verbose {
 105+			logger.SetReportCaller(true)
 106+		}
 107 	}
 108 
 109 	logger.SetTimeFormat(cfg.Log.TimeFormat)
 110diff --git a/server/backend/sqlite/hooks.go b/server/backend/sqlite/hooks.go
 111index ff39046f42207e5deda43d3c6845391bd734ce1b..972b3f31d9be55b4dd2547c82e4cbc797dd4110f 100644
 112--- a/server/backend/sqlite/hooks.go
 113+++ b/server/backend/sqlite/hooks.go
 114@@ -36,16 +36,6 @@ func (d *SqliteBackend) PostUpdate(stdout io.Writer, stderr io.Writer, repo stri
 115 
 116 	var wg sync.WaitGroup
 117 
 118-	// Update server info
 119-	wg.Add(1)
 120-	go func() {
 121-		defer wg.Done()
 122-		if err := updateServerInfo(d, repo); err != nil {
 123-			d.logger.Error("error updating server-info", "repo", repo, "err", err)
 124-			return
 125-		}
 126-	}()
 127-
 128 	// Populate last-modified file.
 129 	wg.Add(1)
 130 	go func() {
 131@@ -59,20 +49,6 @@ func (d *SqliteBackend) PostUpdate(stdout io.Writer, stderr io.Writer, repo stri
 132 	wg.Wait()
 133 }
 134 
 135-func updateServerInfo(d *SqliteBackend, repo string) error {
 136-	rr, err := d.Repository(repo)
 137-	if err != nil {
 138-		return err
 139-	}
 140-
 141-	r, err := rr.Open()
 142-	if err != nil {
 143-		return err
 144-	}
 145-
 146-	return r.UpdateServerInfo()
 147-}
 148-
 149 func populateLastModified(d *SqliteBackend, repo string) error {
 150 	var rr *Repo
 151 	_rr, err := d.Repository(repo)
 152diff --git a/server/backend/sqlite/sqlite.go b/server/backend/sqlite/sqlite.go
 153index 91527e3fcc0a82b8b35e8f1cba9eab55477cd8bb..3273373ea54e180ce3821f9178f2a8d81e5c6031 100644
 154--- a/server/backend/sqlite/sqlite.go
 155+++ b/server/backend/sqlite/sqlite.go
 156@@ -151,17 +151,12 @@ func (d *SqliteBackend) CreateRepository(name string, opts backend.RepositoryOpt
 157 			return err
 158 		}
 159 
 160-		rr, err := git.Init(rp, true)
 161+		_, err := git.Init(rp, true)
 162 		if err != nil {
 163 			d.logger.Debug("failed to create repository", "err", err)
 164 			return err
 165 		}
 166 
 167-		if err := rr.UpdateServerInfo(); err != nil {
 168-			d.logger.Debug("failed to update server info", "err", err)
 169-			return err
 170-		}
 171-
 172 		return nil
 173 	}); err != nil {
 174 		d.logger.Debug("failed to create repository in database", "err", err)
 175diff --git a/server/config/config.go b/server/config/config.go
 176index 2dca4fada1dfb5ce10e2c2cba76693113ebf76a2..c8b9fa9bf159e24edcb4ce52f89374a66d6eb925 100644
 177--- a/server/config/config.go
 178+++ b/server/config/config.go
 179@@ -114,6 +114,40 @@ type Config struct {
 180 	Backend backend.Backend `yaml:"-"`
 181 }
 182 
 183+// Environ returns the config as a list of environment variables.
 184+func (c *Config) Environ() []string {
 185+	envs := []string{}
 186+	if c == nil {
 187+		return envs
 188+	}
 189+
 190+	// TODO: do this dynamically
 191+	envs = append(envs, []string{
 192+		fmt.Sprintf("SOFT_SERVE_NAME=%s", c.Name),
 193+		fmt.Sprintf("SOFT_SERVE_DATA_PATH=%s", c.DataPath),
 194+		fmt.Sprintf("SOFT_SERVE_INITIAL_ADMIN_KEYS=%s", strings.Join(c.InitialAdminKeys, "\n")),
 195+		fmt.Sprintf("SOFT_SERVE_SSH_LISTEN_ADDR=%s", c.SSH.ListenAddr),
 196+		fmt.Sprintf("SOFT_SERVE_SSH_PUBLIC_URL=%s", c.SSH.PublicURL),
 197+		fmt.Sprintf("SOFT_SERVE_SSH_KEY_PATH=%s", c.SSH.KeyPath),
 198+		fmt.Sprintf("SOFT_SERVE_SSH_CLIENT_KEY_PATH=%s", c.SSH.ClientKeyPath),
 199+		fmt.Sprintf("SOFT_SERVE_SSH_MAX_TIMEOUT=%d", c.SSH.MaxTimeout),
 200+		fmt.Sprintf("SOFT_SERVE_SSH_IDLE_TIMEOUT=%d", c.SSH.IdleTimeout),
 201+		fmt.Sprintf("SOFT_SERVE_GIT_LISTEN_ADDR=%s", c.Git.ListenAddr),
 202+		fmt.Sprintf("SOFT_SERVE_GIT_MAX_TIMEOUT=%d", c.Git.MaxTimeout),
 203+		fmt.Sprintf("SOFT_SERVE_GIT_IDLE_TIMEOUT=%d", c.Git.IdleTimeout),
 204+		fmt.Sprintf("SOFT_SERVE_GIT_MAX_CONNECTIONS=%d", c.Git.MaxConnections),
 205+		fmt.Sprintf("SOFT_SERVE_HTTP_LISTEN_ADDR=%s", c.HTTP.ListenAddr),
 206+		fmt.Sprintf("SOFT_SERVE_HTTP_TLS_KEY_PATH=%s", c.HTTP.TLSKeyPath),
 207+		fmt.Sprintf("SOFT_SERVE_HTTP_TLS_CERT_PATH=%s", c.HTTP.TLSCertPath),
 208+		fmt.Sprintf("SOFT_SERVE_HTTP_PUBLIC_URL=%s", c.HTTP.PublicURL),
 209+		fmt.Sprintf("SOFT_SERVE_STATS_LISTEN_ADDR=%s", c.Stats.ListenAddr),
 210+		fmt.Sprintf("SOFT_SERVE_LOG_FORMAT=%s", c.Log.Format),
 211+		fmt.Sprintf("SOFT_SERVE_LOG_TIME_FORMAT=%s", c.Log.TimeFormat),
 212+	}...)
 213+
 214+	return envs
 215+}
 216+
 217 func parseConfig(path string) (*Config, error) {
 218 	dataPath := filepath.Dir(path)
 219 	cfg := &Config{
 220diff --git a/server/daemon/conn.go b/server/daemon/conn.go
 221new file mode 100644
 222index 0000000000000000000000000000000000000000..090d76aeecc3ff3ab847e036753ddfdaa3c3705b
 223--- /dev/null
 224+++ b/server/daemon/conn.go
 225@@ -0,0 +1,105 @@
 226+package daemon
 227+
 228+import (
 229+	"context"
 230+	"errors"
 231+	"net"
 232+	"sync"
 233+	"time"
 234+)
 235+
 236+// connections is a synchronizes access to to a net.Conn pool.
 237+type connections struct {
 238+	m  map[net.Conn]struct{}
 239+	mu sync.Mutex
 240+}
 241+
 242+func (m *connections) Add(c net.Conn) {
 243+	m.mu.Lock()
 244+	defer m.mu.Unlock()
 245+	m.m[c] = struct{}{}
 246+}
 247+
 248+func (m *connections) Close(c net.Conn) error {
 249+	m.mu.Lock()
 250+	defer m.mu.Unlock()
 251+	err := c.Close()
 252+	delete(m.m, c)
 253+	return err
 254+}
 255+
 256+func (m *connections) Size() int {
 257+	m.mu.Lock()
 258+	defer m.mu.Unlock()
 259+	return len(m.m)
 260+}
 261+
 262+func (m *connections) CloseAll() error {
 263+	m.mu.Lock()
 264+	defer m.mu.Unlock()
 265+	var err error
 266+	for c := range m.m {
 267+		err = errors.Join(err, c.Close())
 268+		delete(m.m, c)
 269+	}
 270+
 271+	return err
 272+}
 273+
 274+// serverConn is a wrapper around a net.Conn that closes the connection when
 275+// the one of the timeouts is reached.
 276+type serverConn struct {
 277+	net.Conn
 278+
 279+	initTimeout   time.Duration
 280+	idleTimeout   time.Duration
 281+	maxDeadline   time.Time
 282+	closeCanceler context.CancelFunc
 283+}
 284+
 285+var _ net.Conn = (*serverConn)(nil)
 286+
 287+func (c *serverConn) Write(p []byte) (n int, err error) {
 288+	c.updateDeadline()
 289+	n, err = c.Conn.Write(p)
 290+	if _, isNetErr := err.(net.Error); isNetErr && c.closeCanceler != nil {
 291+		c.closeCanceler()
 292+	}
 293+	return
 294+}
 295+
 296+func (c *serverConn) Read(b []byte) (n int, err error) {
 297+	c.updateDeadline()
 298+	n, err = c.Conn.Read(b)
 299+	if _, isNetErr := err.(net.Error); isNetErr && c.closeCanceler != nil {
 300+		c.closeCanceler()
 301+	}
 302+	return
 303+}
 304+
 305+func (c *serverConn) Close() (err error) {
 306+	err = c.Conn.Close()
 307+	if c.closeCanceler != nil {
 308+		c.closeCanceler()
 309+	}
 310+	return
 311+}
 312+
 313+func (c *serverConn) updateDeadline() {
 314+	switch {
 315+	case c.initTimeout > 0:
 316+		initTimeout := time.Now().Add(c.initTimeout)
 317+		c.initTimeout = 0
 318+		if initTimeout.Unix() < c.maxDeadline.Unix() || c.maxDeadline.IsZero() {
 319+			c.Conn.SetDeadline(initTimeout)
 320+			return
 321+		}
 322+	case c.idleTimeout > 0:
 323+		idleDeadline := time.Now().Add(c.idleTimeout)
 324+		if idleDeadline.Unix() < c.maxDeadline.Unix() || c.maxDeadline.IsZero() {
 325diff --git a/server/daemon/daemon.go b/server/daemon/daemon.go
 326index 944f0eedb9c955dfd1914f3236541b28a5ac38a2..1820c0e3e2c141f79488dac97537e5655a2ad4d5 100644
 327--- a/server/daemon/daemon.go
 328+++ b/server/daemon/daemon.go
 329@@ -6,6 +6,7 @@ import (
 330 	"fmt"
 331 	"net"
 332 	"path/filepath"
 333+	"strings"
 334 	"sync"
 335 	"time"
 336 
 337@@ -41,40 +42,6 @@ var (
 338 	ErrServerClosed = fmt.Errorf("git: %w", net.ErrClosed)
 339 )
 340 
 341-// connections synchronizes access to to a net.Conn pool.
 342-type connections struct {
 343-	m  map[net.Conn]struct{}
 344-	mu sync.Mutex
 345-}
 346-
 347-func (m *connections) Add(c net.Conn) {
 348-	m.mu.Lock()
 349-	defer m.mu.Unlock()
 350-	m.m[c] = struct{}{}
 351-}
 352-
 353-func (m *connections) Close(c net.Conn) {
 354-	m.mu.Lock()
 355-	defer m.mu.Unlock()
 356-	_ = c.Close()
 357-	delete(m.m, c)
 358-}
 359-
 360-func (m *connections) Size() int {
 361-	m.mu.Lock()
 362-	defer m.mu.Unlock()
 363-	return len(m.m)
 364-}
 365-
 366-func (m *connections) CloseAll() {
 367-	m.mu.Lock()
 368-	defer m.mu.Unlock()
 369-	for c := range m.m {
 370-		_ = c.Close()
 371-		delete(m.m, c)
 372-	}
 373-}
 374-
 375 // GitDaemon represents a Git daemon.
 376 type GitDaemon struct {
 377 	ctx      context.Context
 378@@ -213,26 +180,53 @@ func (d *GitDaemon) handleClient(conn net.Conn) {
 379 			return
 380 		}
 381 
 382-		gitPack := git.UploadPack
 383-		counter := uploadPackGitCounter
 384-		cmd := string(split[0])
 385-		switch cmd {
 386-		case git.UploadPackBin:
 387-			gitPack = git.UploadPack
 388-		case git.UploadArchiveBin:
 389-			gitPack = git.UploadArchive
 390+		var handler git.ServiceHandler
 391+		var counter *prometheus.CounterVec
 392+		service := git.Service(split[0])
 393+		switch service {
 394+		case git.UploadPackService:
 395+			handler = git.UploadPack
 396+			counter = uploadPackGitCounter
 397+		case git.UploadArchiveService:
 398+			handler = git.UploadArchive
 399 			counter = uploadArchiveGitCounter
 400 		default:
 401 			d.fatal(c, git.ErrInvalidRequest)
 402 			return
 403 		}
 404 
 405-		opts := bytes.Split(split[1], []byte{'\x00'})
 406-		if len(opts) == 0 {
 407-			d.fatal(c, git.ErrInvalidRequest)
 408+		opts := bytes.SplitN(split[1], []byte{0}, 3)
 409+		if len(opts) < 2 {
 410+			d.fatal(c, git.ErrInvalidRequest) // nolint: errcheck
 411 			return
 412 		}
 413 
 414+		host := strings.TrimPrefix(string(opts[1]), "host=")
 415+		extraParams := map[string]string{}
 416+
 417+		if len(opts) > 2 {
 418+			buf := bytes.TrimPrefix(opts[2], []byte{0})
 419+			for _, o := range bytes.Split(buf, []byte{0}) {
 420+				opt := string(o)
 421+				if opt == "" {
 422+					continue
 423+				}
 424+
 425+				kv := strings.SplitN(opt, "=", 2)
 426+				if len(kv) != 2 {
 427+					d.logger.Errorf("git: invalid option %q", opt)
 428+					continue
 429diff --git a/server/git/git.go b/server/git/git.go
 430index ef8affe207a0cf4993976cbcc28fe91e5e9d512f..8f8ae3d7c6ecd30e68c55c0d53d5029f18a5445a 100644
 431--- a/server/git/git.go
 432+++ b/server/git/git.go
 433@@ -5,16 +5,12 @@ import (
 434 	"errors"
 435 	"fmt"
 436 	"io"
 437-	"os"
 438-	"os/exec"
 439 	"path/filepath"
 440 	"strings"
 441 
 442 	"github.com/charmbracelet/log"
 443 	"github.com/charmbracelet/soft-serve/git"
 444-	"github.com/charmbracelet/soft-serve/server/config"
 445 	"github.com/go-git/go-git/v5/plumbing/format/pktline"
 446-	"golang.org/x/sync/errgroup"
 447 )
 448 
 449 var (
 450@@ -38,112 +34,6 @@ var (
 451 	ErrTimeout = errors.New("I/O timeout reached")
 452 )
 453 
 454-// Git protocol commands.
 455-const (
 456-	ReceivePackBin   = "git-receive-pack"
 457-	UploadPackBin    = "git-upload-pack"
 458-	UploadArchiveBin = "git-upload-archive"
 459-)
 460-
 461-// UploadPack runs the git upload-pack protocol against the provided repo.
 462-func UploadPack(ctx context.Context, in io.Reader, out io.Writer, er io.Writer, repoDir string, envs ...string) error {
 463-	exists, err := fileExists(repoDir)
 464-	if !exists {
 465-		return ErrInvalidRepo
 466-	}
 467-	if err != nil {
 468-		return err
 469-	}
 470-	return RunGit(ctx, in, out, er, "", envs, UploadPackBin[4:], repoDir)
 471-}
 472-
 473-// UploadArchive runs the git upload-archive protocol against the provided repo.
 474-func UploadArchive(ctx context.Context, in io.Reader, out io.Writer, er io.Writer, repoDir string, envs ...string) error {
 475-	exists, err := fileExists(repoDir)
 476-	if !exists {
 477-		return ErrInvalidRepo
 478-	}
 479-	if err != nil {
 480-		return err
 481-	}
 482-	return RunGit(ctx, in, out, er, "", envs, UploadArchiveBin[4:], repoDir)
 483-}
 484-
 485-// ReceivePack runs the git receive-pack protocol against the provided repo.
 486-func ReceivePack(ctx context.Context, in io.Reader, out io.Writer, er io.Writer, repoDir string, envs ...string) error {
 487-	if err := RunGit(ctx, in, out, er, "", envs, ReceivePackBin[4:], repoDir); err != nil {
 488-		return err
 489-	}
 490-	return EnsureDefaultBranch(ctx, in, out, er, repoDir)
 491-}
 492-
 493-// RunGit runs a git command in the given repo.
 494-func RunGit(ctx context.Context, in io.Reader, out io.Writer, er io.Writer, dir string, envs []string, args ...string) error {
 495-	cfg := config.FromContext(ctx)
 496-	logger := log.FromContext(ctx).WithPrefix("rungit")
 497-	c := exec.CommandContext(ctx, "git", args...)
 498-	c.Dir = dir
 499-	c.Env = append(os.Environ(), envs...)
 500-	c.Env = append(c.Env, "PATH="+os.Getenv("PATH"))
 501-	c.Env = append(c.Env, "SOFT_SERVE_DEBUG="+os.Getenv("SOFT_SERVE_DEBUG"))
 502-	if cfg != nil {
 503-		c.Env = append(c.Env, "SOFT_SERVE_LOG_FORMAT="+cfg.Log.Format)
 504-		c.Env = append(c.Env, "SOFT_SERVE_LOG_TIME_FORMAT="+cfg.Log.TimeFormat)
 505-	}
 506-
 507-	stdin, err := c.StdinPipe()
 508-	if err != nil {
 509-		logger.Error("failed to get stdin pipe", "err", err)
 510-		return err
 511-	}
 512-
 513-	stdout, err := c.StdoutPipe()
 514-	if err != nil {
 515-		logger.Error("failed to get stdout pipe", "err", err)
 516-		return err
 517-	}
 518-
 519-	stderr, err := c.StderrPipe()
 520-	if err != nil {
 521-		logger.Error("failed to get stderr pipe", "err", err)
 522-		return err
 523-	}
 524-
 525-	if err := c.Start(); err != nil {
 526-		logger.Error("failed to start command", "err", err)
 527-		return err
 528-	}
 529-
 530-	errg, ctx := errgroup.WithContext(ctx)
 531-
 532-	// stdin
 533diff --git a/server/git/service.go b/server/git/service.go
 534new file mode 100644
 535index 0000000000000000000000000000000000000000..0730018403018a31336f01f8ddd6f38d70eccd9b
 536--- /dev/null
 537+++ b/server/git/service.go
 538@@ -0,0 +1,186 @@
 539+package git
 540+
 541+import (
 542+	"context"
 543+	"errors"
 544+	"fmt"
 545+	"io"
 546+	"os"
 547+	"os/exec"
 548+	"strings"
 549+
 550+	"github.com/charmbracelet/log"
 551+	"golang.org/x/sync/errgroup"
 552+)
 553+
 554+// Service is a Git daemon service.
 555+type Service string
 556+
 557+const (
 558+	// UploadPackService is the upload-pack service.
 559+	UploadPackService Service = "git-upload-pack"
 560+	// UploadArchiveService is the upload-archive service.
 561+	UploadArchiveService Service = "git-upload-archive"
 562+	// ReceivePackService is the receive-pack service.
 563+	ReceivePackService Service = "git-receive-pack"
 564+)
 565+
 566+// String returns the string representation of the service.
 567+func (s Service) String() string {
 568+	return string(s)
 569+}
 570+
 571+// Name returns the name of the service.
 572+func (s Service) Name() string {
 573+	return strings.TrimPrefix(s.String(), "git-")
 574+}
 575+
 576+// Handler is the service handler.
 577+func (s Service) Handler(ctx context.Context, cmd ServiceCommand) error {
 578+	switch s {
 579+	case UploadPackService, UploadArchiveService, ReceivePackService:
 580+		return gitServiceHandler(ctx, s, cmd)
 581+	default:
 582+		return fmt.Errorf("unsupported service: %s", s)
 583+	}
 584+}
 585+
 586+// ServiceHandler is a git service command handler.
 587+type ServiceHandler func(ctx context.Context, cmd ServiceCommand) error
 588+
 589+// gitServiceHandler is the default service handler using the git binary.
 590+func gitServiceHandler(ctx context.Context, svc Service, scmd ServiceCommand) error {
 591+	cmd := exec.CommandContext(ctx, "git", "-c", "uploadpack.allowFilter=true", svc.Name()) // nolint: gosec
 592+	cmd.Dir = scmd.Dir
 593+	if len(scmd.Args) > 0 {
 594+		cmd.Args = append(cmd.Args, scmd.Args...)
 595+	}
 596+
 597+	cmd.Args = append(cmd.Args, ".")
 598+
 599+	cmd.Env = os.Environ()
 600+	if len(scmd.Env) > 0 {
 601+		cmd.Env = append(cmd.Env, scmd.Env...)
 602+	}
 603+
 604+	if scmd.CmdFunc != nil {
 605+		scmd.CmdFunc(cmd)
 606+	}
 607+
 608+	var (
 609+		err    error
 610+		stdin  io.WriteCloser
 611+		stdout io.ReadCloser
 612+		stderr io.ReadCloser
 613+	)
 614+
 615+	if scmd.Stdin != nil {
 616+		stdin, err = cmd.StdinPipe()
 617+		if err != nil {
 618+			return err
 619+		}
 620+	}
 621+
 622+	if scmd.Stdout != nil {
 623+		stdout, err = cmd.StdoutPipe()
 624+		if err != nil {
 625+			return err
 626+		}
 627+	}
 628+
 629+	if scmd.Stderr != nil {
 630+		stderr, err = cmd.StderrPipe()
 631+		if err != nil {
 632+			return err
 633+		}
 634+	}
 635+
 636+	log.Debugf("git service command in %q: %s", cmd.Dir, cmd.String())
 637+	if err := cmd.Start(); err != nil {
 638diff --git a/server/ssh/ssh.go b/server/ssh/ssh.go
 639index 42cd7f5f543044881d79af9725e9c4a55211d277..8e98d0ebc0938776d031615daf8090ff6244f9cc 100644
 640--- a/server/ssh/ssh.go
 641+++ b/server/ssh/ssh.go
 642@@ -216,13 +216,13 @@ func (ss *SSHServer) Middleware(cfg *config.Config) wish.Middleware {
 643 		return func(s ssh.Session) {
 644 			func() {
 645 				start := time.Now()
 646-				cmd := s.Command()
 647+				cmdLine := s.Command()
 648 				ctx := s.Context()
 649 				be := ss.be.WithContext(ctx)
 650-				if len(cmd) >= 2 && strings.HasPrefix(cmd[0], "git") {
 651-					gc := cmd[0]
 652+
 653+				if len(cmdLine) >= 2 && strings.HasPrefix(cmdLine[0], "git") {
 654 					// repo should be in the form of "repo.git"
 655-					name := utils.SanitizeRepo(cmd[1])
 656+					name := utils.SanitizeRepo(cmdLine[1])
 657 					pk := s.PublicKey()
 658 					ak := backend.MarshalAuthorizedKey(pk)
 659 					access := cfg.Backend.AccessLevelByPublicKey(name, pk)
 660@@ -240,12 +240,27 @@ func (ss *SSHServer) Middleware(cfg *config.Config) wish.Middleware {
 661 						"SOFT_SERVE_REPO_NAME=" + name,
 662 						"SOFT_SERVE_REPO_PATH=" + filepath.Join(reposDir, repo),
 663 						"SOFT_SERVE_PUBLIC_KEY=" + ak,
 664+						"SOFT_SERVE_USERNAME=" + ctx.User(),
 665 					}
 666 
 667-					ss.logger.Debug("git middleware", "cmd", gc, "access", access.String())
 668+					// Add ssh session & config environ
 669+					envs = append(envs, s.Environ()...)
 670+					envs = append(envs, cfg.Environ()...)
 671+
 672 					repoDir := filepath.Join(reposDir, repo)
 673-					switch gc {
 674-					case git.ReceivePackBin:
 675+					service := git.Service(cmdLine[0])
 676+					cmd := git.ServiceCommand{
 677+						Stdin:  s,
 678+						Stdout: s,
 679+						Stderr: s.Stderr(),
 680+						Env:    envs,
 681+						Dir:    repoDir,
 682+					}
 683+
 684+					ss.logger.Debug("git middleware", "cmd", service, "access", access.String())
 685+
 686+					switch service {
 687+					case git.ReceivePackService:
 688 						receivePackCounter.WithLabelValues(name).Inc()
 689 						defer func() {
 690 							receivePackSeconds.WithLabelValues(name).Add(time.Since(start).Seconds())
 691@@ -262,20 +277,27 @@ func (ss *SSHServer) Middleware(cfg *config.Config) wish.Middleware {
 692 							}
 693 							createRepoCounter.WithLabelValues(name).Inc()
 694 						}
 695-						if err := git.ReceivePack(s.Context(), s, s, s.Stderr(), repoDir, envs...); err != nil {
 696+
 697+						if err := git.ReceivePack(ctx, cmd); err != nil {
 698+							sshFatal(s, git.ErrSystemMalfunction)
 699+						}
 700+
 701+						if err := git.EnsureDefaultBranch(ctx, cmd); err != nil {
 702 							sshFatal(s, git.ErrSystemMalfunction)
 703 						}
 704+
 705+						receivePackCounter.WithLabelValues(name).Inc()
 706 						return
 707-					case git.UploadPackBin, git.UploadArchiveBin:
 708+					case git.UploadPackService, git.UploadArchiveService:
 709 						if access < backend.ReadOnlyAccess {
 710 							sshFatal(s, git.ErrNotAuthed)
 711 							return
 712 						}
 713 
 714-						gitPack := git.UploadPack
 715-						switch gc {
 716-						case git.UploadArchiveBin:
 717-							gitPack = git.UploadArchive
 718+						handler := git.UploadPack
 719+						switch service {
 720+						case git.UploadArchiveService:
 721+							handler = git.UploadArchive
 722 							uploadArchiveCounter.WithLabelValues(name).Inc()
 723 							defer func() {
 724 								uploadArchiveSeconds.WithLabelValues(name).Add(time.Since(start).Seconds())
 725@@ -285,10 +307,9 @@ func (ss *SSHServer) Middleware(cfg *config.Config) wish.Middleware {
 726 							defer func() {
 727 								uploadPackSeconds.WithLabelValues(name).Add(time.Since(start).Seconds())
 728 							}()
 729-
 730 						}
 731 
 732-						err := gitPack(ctx, s, s, s.Stderr(), repoDir, envs...)
 733+						err := handler(ctx, cmd)
 734 						if errors.Is(err, git.ErrInvalidRepo) {
 735 							sshFatal(s, git.ErrInvalidRepo)
 736 						} else if err != nil {
 737diff --git a/server/web/git.go b/server/web/git.go
 738new file mode 100644
 739index 0000000000000000000000000000000000000000..2ca9265912dc2af5c94f2d629ec27b230b811afa
 740--- /dev/null
 741+++ b/server/web/git.go
 742@@ -0,0 +1,459 @@
 743+package web
 744+
 745+import (
 746+	"bytes"
 747+	"compress/gzip"
 748+	"context"
 749+	"fmt"
 750+	"io"
 751+	"net/http"
 752+	"os"
 753+	"path/filepath"
 754+	"regexp"
 755+	"strings"
 756+	"time"
 757+
 758+	"github.com/charmbracelet/log"
 759+	gitb "github.com/charmbracelet/soft-serve/git"
 760+	"github.com/charmbracelet/soft-serve/server/backend"
 761+	"github.com/charmbracelet/soft-serve/server/config"
 762+	"github.com/charmbracelet/soft-serve/server/git"
 763+	"github.com/charmbracelet/soft-serve/server/utils"
 764+	"github.com/prometheus/client_golang/prometheus"
 765+	"github.com/prometheus/client_golang/prometheus/promauto"
 766+	"goji.io/pat"
 767+	"goji.io/pattern"
 768+)
 769+
 770+// GitRoute is a route for git services.
 771+type GitRoute struct {
 772+	method  string
 773+	pattern *regexp.Regexp
 774+	handler http.HandlerFunc
 775+
 776+	cfg    *config.Config
 777+	be     backend.Backend
 778+	logger *log.Logger
 779+}
 780+
 781+var _ Route = GitRoute{}
 782+
 783+// Match implements goji.Pattern.
 784+func (g GitRoute) Match(r *http.Request) *http.Request {
 785+	if g.method != r.Method {
 786+		return nil
 787+	}
 788+
 789+	re := g.pattern
 790+	ctx := r.Context()
 791+	if m := re.FindStringSubmatch(r.URL.Path); m != nil {
 792+		file := strings.Replace(r.URL.Path, m[1]+"/", "", 1)
 793+		repo := utils.SanitizeRepo(m[1]) + ".git"
 794+
 795+		var service git.Service
 796+		switch {
 797+		case strings.HasSuffix(r.URL.Path, git.UploadPackService.String()):
 798+			service = git.UploadPackService
 799+		case strings.HasSuffix(r.URL.Path, git.ReceivePackService.String()):
 800+			service = git.ReceivePackService
 801+		}
 802+
 803+		ctx = context.WithValue(ctx, pattern.Variable("service"), service.String())
 804+		ctx = context.WithValue(ctx, pattern.Variable("dir"), filepath.Join(g.cfg.DataPath, "repos", repo))
 805+		ctx = context.WithValue(ctx, pattern.Variable("repo"), repo)
 806+		ctx = context.WithValue(ctx, pattern.Variable("file"), file)
 807+
 808+		if g.cfg != nil {
 809+			ctx = config.WithContext(ctx, g.cfg)
 810+		}
 811+
 812+		if g.be != nil {
 813+			ctx = backend.WithContext(ctx, g.be.WithContext(ctx))
 814+		}
 815+
 816+		if g.logger != nil {
 817+			ctx = log.WithContext(ctx, g.logger)
 818+		}
 819+
 820+		return r.WithContext(ctx)
 821+	}
 822+
 823+	return nil
 824+}
 825+
 826+// ServeHTTP implements http.Handler.
 827+func (g GitRoute) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 828+	g.handler(w, r)
 829+}
 830+
 831+var (
 832+	gitHttpReceiveCounter = promauto.NewCounterVec(prometheus.CounterOpts{
 833+		Namespace: "soft_serve",
 834+		Subsystem: "http",
 835+		Name:      "git_receive_pack_total",
 836+		Help:      "The total number of git push requests",
 837+	}, []string{"repo"})
 838+
 839+	gitHttpUploadCounter = promauto.NewCounterVec(prometheus.CounterOpts{
 840+		Namespace: "soft_serve",
 841+		Subsystem: "http",
 842diff --git a/server/web/goget.go b/server/web/goget.go
 843new file mode 100644
 844index 0000000000000000000000000000000000000000..7e7c8c9d61faefd0b8bc629751e1d7328fa1c042
 845--- /dev/null
 846+++ b/server/web/goget.go
 847@@ -0,0 +1,94 @@
 848+package web
 849+
 850+import (
 851+	"net/http"
 852+	"net/url"
 853+	"path"
 854+	"text/template"
 855+
 856+	"github.com/charmbracelet/soft-serve/server/backend"
 857+	"github.com/charmbracelet/soft-serve/server/config"
 858+	"github.com/charmbracelet/soft-serve/server/utils"
 859+	"github.com/prometheus/client_golang/prometheus"
 860+	"github.com/prometheus/client_golang/prometheus/promauto"
 861+	"goji.io/pattern"
 862+)
 863+
 864+var goGetCounter = promauto.NewCounterVec(prometheus.CounterOpts{
 865+	Namespace: "soft_serve",
 866+	Subsystem: "http",
 867+	Name:      "go_get_total",
 868+	Help:      "The total number of go get requests",
 869+}, []string{"repo"})
 870+
 871+var repoIndexHTMLTpl = template.Must(template.New("index").Parse(`<!DOCTYPE html>
 872+<html lang="en">
 873+<head>
 874+    <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
 875+    <meta http-equiv="refresh" content="0; url=https://godoc.org/{{ .ImportRoot }}/{{.Repo}}">
 876+    <meta name="go-import" content="{{ .ImportRoot }}/{{ .Repo }} git {{ .Config.HTTP.PublicURL }}/{{ .Repo }}">
 877+</head>
 878+<body>
 879+Redirecting to docs at <a href="https://godoc.org/{{ .ImportRoot }}/{{ .Repo }}">godoc.org/{{ .ImportRoot }}/{{ .Repo }}</a>...
 880+</body>
 881+</html>`))
 882+
 883+// GoGetHandler handles go get requests.
 884+type GoGetHandler struct {
 885+	cfg *config.Config
 886+	be  backend.Backend
 887+}
 888+
 889+var _ http.Handler = (*GoGetHandler)(nil)
 890+
 891+func (g GoGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 892+	repo := pattern.Path(r.Context())
 893+	repo = utils.SanitizeRepo(repo)
 894+	be := g.be.WithContext(r.Context())
 895+
 896+	// Handle go get requests.
 897+	//
 898+	// Always return a 200 status code, even if the repo doesn't exist.
 899+	//
 900+	// https://golang.org/cmd/go/#hdr-Remote_import_paths
 901+	// https://go.dev/ref/mod#vcs-branch
 902+	if r.URL.Query().Get("go-get") == "1" {
 903+		repo := repo
 904+		importRoot, err := url.Parse(g.cfg.HTTP.PublicURL)
 905+		if err != nil {
 906+			http.Error(w, err.Error(), http.StatusInternalServerError)
 907+			return
 908+		}
 909+
 910+		// find the repo
 911+		for {
 912+			if _, err := be.Repository(repo); err == nil {
 913+				break
 914+			}
 915+
 916+			if repo == "" || repo == "." || repo == "/" {
 917+				return
 918+			}
 919+
 920+			repo = path.Dir(repo)
 921+		}
 922+
 923+		if err := repoIndexHTMLTpl.Execute(w, struct {
 924+			Repo       string
 925+			Config     *config.Config
 926+			ImportRoot string
 927+		}{
 928+			Repo:       url.PathEscape(repo),
 929+			Config:     g.cfg,
 930+			ImportRoot: importRoot.Host,
 931+		}); err != nil {
 932+			http.Error(w, err.Error(), http.StatusInternalServerError)
 933+			return
 934+		}
 935+
 936+		goGetCounter.WithLabelValues(repo).Inc()
 937+		return
 938+	}
 939+
 940+	http.NotFound(w, r)
 941+}
 942diff --git a/server/web/http.go b/server/web/http.go
 943index b932f94162bf6c2c6119e451b682d2a132c7e233..7ff375cab3132d377d2f92e0ce5184dc94ce43a0 100644
 944--- a/server/web/http.go
 945+++ b/server/web/http.go
 946@@ -2,103 +2,31 @@ package web
 947 
 948 import (
 949 	"context"
 950-	"fmt"
 951 	"net/http"
 952-	"net/url"
 953-	"path"
 954-	"path/filepath"
 955-	"regexp"
 956-	"strings"
 957-	"text/template"
 958 	"time"
 959 
 960-	"github.com/charmbracelet/log"
 961 	"github.com/charmbracelet/soft-serve/server/backend"
 962 	"github.com/charmbracelet/soft-serve/server/config"
 963-	"github.com/charmbracelet/soft-serve/server/utils"
 964-	"github.com/dustin/go-humanize"
 965-	"github.com/prometheus/client_golang/prometheus"
 966-	"github.com/prometheus/client_golang/prometheus/promauto"
 967-	"goji.io"
 968-	"goji.io/pat"
 969-	"goji.io/pattern"
 970 )
 971 
 972-var (
 973-	gitHttpCounter = promauto.NewCounterVec(prometheus.CounterOpts{
 974-		Namespace: "soft_serve",
 975-		Subsystem: "http",
 976-		Name:      "git_fetch_pull_total",
 977-		Help:      "The total number of git fetch/pull requests",
 978-	}, []string{"repo", "file"})
 979-
 980-	goGetCounter = promauto.NewCounterVec(prometheus.CounterOpts{
 981-		Namespace: "soft_serve",
 982-		Subsystem: "http",
 983-		Name:      "go_get_total",
 984-		Help:      "The total number of go get requests",
 985-	}, []string{"repo"})
 986-)
 987-
 988-// logWriter is a wrapper around http.ResponseWriter that allows us to capture
 989-// the HTTP status code and bytes written to the response.
 990-type logWriter struct {
 991-	http.ResponseWriter
 992-	code, bytes int
 993-}
 994-
 995-func (r *logWriter) Write(p []byte) (int, error) {
 996-	written, err := r.ResponseWriter.Write(p)
 997-	r.bytes += written
 998-	return written, err
 999-}
1000-
1001-// Note this is generally only called when sending an HTTP error, so it's
1002-// important to set the `code` value to 200 as a default
1003-func (r *logWriter) WriteHeader(code int) {
1004-	r.code = code
1005-	r.ResponseWriter.WriteHeader(code)
1006-}
1007-
1008-func (s *HTTPServer) loggingMiddleware(next http.Handler) http.Handler {
1009-	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1010-		start := time.Now()
1011-		writer := &logWriter{code: http.StatusOK, ResponseWriter: w}
1012-		s.logger.Debug("request",
1013-			"method", r.Method,
1014-			"uri", r.RequestURI,
1015-			"addr", r.RemoteAddr)
1016-		next.ServeHTTP(writer, r)
1017-		elapsed := time.Since(start)
1018-		s.logger.Debug("response",
1019-			"status", fmt.Sprintf("%d %s", writer.code, http.StatusText(writer.code)),
1020-			"bytes", humanize.Bytes(uint64(writer.bytes)),
1021-			"time", elapsed)
1022-	})
1023-}
1024-
1025 // HTTPServer is an http server.
1026 type HTTPServer struct {
1027-	ctx        context.Context
1028-	cfg        *config.Config
1029-	be         backend.Backend
1030-	server     *http.Server
1031-	dirHandler http.Handler
1032-	logger     *log.Logger
1033+	ctx    context.Context
1034+	cfg    *config.Config
1035+	be     backend.Backend
1036+	server *http.Server
1037 }
1038 
1039+// NewHTTPServer creates a new HTTP server.
1040 func NewHTTPServer(ctx context.Context) (*HTTPServer, error) {
1041 	cfg := config.FromContext(ctx)
1042-	mux := goji.NewMux()
1043 	s := &HTTPServer{
1044-		ctx:        ctx,
1045-		cfg:        cfg,
1046diff --git a/server/web/logging.go b/server/web/logging.go
1047new file mode 100644
1048index 0000000000000000000000000000000000000000..f0f43a05c832cc98b2edc24ede2c83254b6d1b95
1049--- /dev/null
1050+++ b/server/web/logging.go
1051@@ -0,0 +1,84 @@
1052+package web
1053+
1054+import (
1055+	"bufio"
1056+	"fmt"
1057+	"net"
1058+	"net/http"
1059+	"time"
1060+
1061+	"github.com/charmbracelet/log"
1062+	"github.com/dustin/go-humanize"
1063+)
1064+
1065+// logWriter is a wrapper around http.ResponseWriter that allows us to capture
1066+// the HTTP status code and bytes written to the response.
1067+type logWriter struct {
1068+	http.ResponseWriter
1069+	code, bytes int
1070+}
1071+
1072+var _ http.ResponseWriter = (*logWriter)(nil)
1073+
1074+var _ http.Flusher = (*logWriter)(nil)
1075+
1076+var _ http.Hijacker = (*logWriter)(nil)
1077+
1078+var _ http.CloseNotifier = (*logWriter)(nil)
1079+
1080+// Write implements http.ResponseWriter.
1081+func (r *logWriter) Write(p []byte) (int, error) {
1082+	written, err := r.ResponseWriter.Write(p)
1083+	r.bytes += written
1084+	return written, err
1085+}
1086+
1087+// Note this is generally only called when sending an HTTP error, so it's
1088+// important to set the `code` value to 200 as a default.
1089+func (r *logWriter) WriteHeader(code int) {
1090+	r.code = code
1091+	r.ResponseWriter.WriteHeader(code)
1092+}
1093+
1094+// Flush implements http.Flusher.
1095+func (r *logWriter) Flush() {
1096+	if f, ok := r.ResponseWriter.(http.Flusher); ok {
1097+		f.Flush()
1098+	}
1099+}
1100+
1101+// CloseNotify implements http.CloseNotifier.
1102+func (r *logWriter) CloseNotify() <-chan bool {
1103+	if cn, ok := r.ResponseWriter.(http.CloseNotifier); ok {
1104+		return cn.CloseNotify()
1105+	}
1106+	return nil
1107+}
1108+
1109+// Hijack implements http.Hijacker.
1110+func (r *logWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
1111+	if h, ok := r.ResponseWriter.(http.Hijacker); ok {
1112+		return h.Hijack()
1113+	}
1114+	return nil, nil, fmt.Errorf("http.Hijacker not implemented")
1115+}
1116+
1117+// NewLoggingMiddleware returns a new logging middleware.
1118+func NewLoggingMiddleware(logger *log.Logger) func(http.Handler) http.Handler {
1119+	return func(next http.Handler) http.Handler {
1120+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1121+			start := time.Now()
1122+			writer := &logWriter{code: http.StatusOK, ResponseWriter: w}
1123+			logger.Debug("request",
1124+				"method", r.Method,
1125+				"uri", r.RequestURI,
1126+				"addr", r.RemoteAddr)
1127+			next.ServeHTTP(writer, r)
1128+			elapsed := time.Since(start)
1129+			logger.Debug("response",
1130+				"status", fmt.Sprintf("%d %s", writer.code, http.StatusText(writer.code)),
1131+				"bytes", humanize.Bytes(uint64(writer.bytes)),
1132+				"time", elapsed)
1133+		})
1134+	}
1135+}
1136diff --git a/server/web/server.go b/server/web/server.go
1137new file mode 100644
1138index 0000000000000000000000000000000000000000..ea15e778fbd2b4d131c75f9bd6f6f37b939e6be7
1139--- /dev/null
1140+++ b/server/web/server.go
1141@@ -0,0 +1,40 @@
1142+// Package server is the reusable server
1143+package web
1144+
1145+import (
1146+	"context"
1147+	"net/http"
1148+
1149+	"github.com/charmbracelet/log"
1150+	"github.com/charmbracelet/soft-serve/server/backend"
1151+	"github.com/charmbracelet/soft-serve/server/config"
1152+	"goji.io"
1153+	"goji.io/pat"
1154+)
1155+
1156+// Route is an interface for a route.
1157+type Route interface {
1158+	http.Handler
1159+	goji.Pattern
1160+}
1161+
1162+// NewRouter returns a new HTTP router.
1163+func NewRouter(ctx context.Context) *goji.Mux {
1164+	mux := goji.NewMux()
1165+	cfg := config.FromContext(ctx)
1166+	be := backend.FromContext(ctx)
1167+	logger := log.FromContext(ctx).WithPrefix("http")
1168+
1169+	// Middlewares
1170+	mux.Use(NewLoggingMiddleware(logger))
1171+
1172+	// Git routes
1173+	for _, service := range gitRoutes(ctx, logger) {
1174+		mux.Handle(service, service)
1175+	}
1176+
1177+	// go-get handler
1178+	mux.Handle(pat.Get("/*"), GoGetHandler{cfg, be})
1179+
1180+	return mux
1181+}