ce5ab220112d85a0b77c78fe32fee1d0bad71cea

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

Message

feat(server): rework git hooks and drop internal server

Improve performance and drop ssh internal hooks server

Diff

This diff is truncated to protect this page.

   1diff --git a/cmd/soft/hook.go b/cmd/soft/hook.go
   2index de16e3e89e0c14e8e040d7b1140375ca4f04dd4a..a24fc6b33c20ee7e1e83147beb6112f7f0b368a2 100644
   3--- a/cmd/soft/hook.go
   4+++ b/cmd/soft/hook.go
   5@@ -1,15 +1,23 @@
   6 package main
   7 
   8 import (
   9+	"bufio"
  10+	"bytes"
  11+	"context"
  12 	"fmt"
  13 	"os"
  14-	"path/filepath"
  15 	"strings"
  16 
  17-	"github.com/charmbracelet/keygen"
  18+	"github.com/charmbracelet/soft-serve/server/backend"
  19+	"github.com/charmbracelet/soft-serve/server/backend/sqlite"
  20 	"github.com/charmbracelet/soft-serve/server/config"
  21+	"github.com/charmbracelet/soft-serve/server/hooks"
  22 	"github.com/spf13/cobra"
  23-	gossh "golang.org/x/crypto/ssh"
  24+)
  25+
  26+var (
  27+	confixCtxKey  = "config"
  28+	backendCtxKey = "backend"
  29 )
  30 
  31 var (
  32@@ -20,94 +28,113 @@ var (
  33 		Short:  "Run git server hooks",
  34 		Long:   "Handles Soft Serve git server hooks.",
  35 		Hidden: true,
  36-		RunE: func(_ *cobra.Command, args []string) error {
  37-			c, s, err := commonInit()
  38+		PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
  39+			cfg, err := config.ParseConfig(configPath)
  40 			if err != nil {
  41-				return err
  42+				return fmt.Errorf("could not parse config: %w", err)
  43 			}
  44-			defer c.Close() //nolint:errcheck
  45-			defer s.Close() //nolint:errcheck
  46-			s.Stdin = os.Stdin
  47-			s.Stdout = os.Stdout
  48-			s.Stderr = os.Stderr
  49-			cmd := fmt.Sprintf("hook %s", strings.Join(args, " "))
  50-			if err := s.Run(cmd); err != nil {
  51-				return err
  52+
  53+			// Set up the backend
  54+			// TODO: support other backends
  55+			sb, err := sqlite.NewSqliteBackend(cmd.Context(), cfg)
  56+			if err != nil {
  57+				return fmt.Errorf("failed to create sqlite backend: %w", err)
  58 			}
  59+
  60+			cfg = cfg.WithBackend(sb)
  61+
  62+			cmd.SetContext(context.WithValue(cmd.Context(), confixCtxKey, cfg))
  63+			cmd.SetContext(context.WithValue(cmd.Context(), backendCtxKey, sb))
  64+
  65 			return nil
  66 		},
  67 	}
  68-)
  69 
  70-func init() {
  71-	hookCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "path to config file")
  72-}
  73+	hooksRunE = func(cmd *cobra.Command, args []string) error {
  74+		cfg := cmd.Context().Value(confixCtxKey).(*config.Config)
  75+		hks := cfg.Backend.(backend.Hooks)
  76 
  77-// TODO: use ssh controlmaster
  78-func commonInit() (c *gossh.Client, s *gossh.Session, err error) {
  79-	cfg, err := config.ParseConfig(configPath)
  80-	if err != nil {
  81-		return
  82-	}
  83+		// This is set in the server before invoking git-receive-pack/git-upload-pack
  84+		repoName := os.Getenv("SOFT_SERVE_REPO_NAME")
  85 
  86-	// Git runs the hook within the repository's directory.
  87-	// Get the working directory to determine the repository name.
  88-	wd, err := os.Getwd()
  89-	if err != nil {
  90-		return
  91-	}
  92+		in := cmd.InOrStdin()
  93+		out := cmd.OutOrStdout()
  94+		err := cmd.ErrOrStderr()
  95 
  96-	rs, err := filepath.Abs(filepath.Join(cfg.DataPath, "repos"))
  97-	if err != nil {
  98-		return
  99-	}
 100+		cmdName := cmd.Name()
 101+		switch cmdName {
 102+		case hooks.PreReceiveHook, hooks.PostReceiveHook:
 103+			var buf bytes.Buffer
 104+			opts := make([]backend.HookArg, 0)
 105diff --git a/server/backend/backend.go b/server/backend/backend.go
 106index ae243d1a1e6232c09f048b5d8519f9bd3268b801..308f342794b03cc0d416c0530d5529b915b16695 100644
 107--- a/server/backend/backend.go
 108+++ b/server/backend/backend.go
 109@@ -16,6 +16,7 @@ type Backend interface {
 110 	RepositoryAccess
 111 	UserStore
 112 	UserAccess
 113+	Hooks
 114 }
 115 
 116 // ParseAuthorizedKey parses an authorized key string into a public key.
 117diff --git a/server/backend/hooks.go b/server/backend/hooks.go
 118new file mode 100644
 119index 0000000000000000000000000000000000000000..ba130a30195a734a8b0626524dd6e9c9c9a0c056
 120--- /dev/null
 121+++ b/server/backend/hooks.go
 122@@ -0,0 +1,20 @@
 123+package backend
 124+
 125+import (
 126+	"io"
 127+)
 128+
 129+// HookArg is an argument to a git hook.
 130+type HookArg struct {
 131+	OldSha  string
 132+	NewSha  string
 133+	RefName string
 134+}
 135+
 136+// Hooks provides an interface for git server-side hooks.
 137+type Hooks interface {
 138+	PreReceive(stdout io.Writer, stderr io.Writer, repo string, args []HookArg)
 139+	Update(stdout io.Writer, stderr io.Writer, repo string, arg HookArg)
 140+	PostReceive(stdout io.Writer, stderr io.Writer, repo string, args []HookArg)
 141+	PostUpdate(stdout io.Writer, stderr io.Writer, repo string, args ...string)
 142+}
 143diff --git a/server/backend/repo.go b/server/backend/repo.go
 144index 7172d71ee8bf575654c3549ff10f87ec7e5194bc..8d7e9cdeffa516b70c276c383b4d78746ead6876 100644
 145--- a/server/backend/repo.go
 146+++ b/server/backend/repo.go
 147@@ -29,8 +29,6 @@ type RepositoryStore interface {
 148 	DeleteRepository(name string) error
 149 	// RenameRepository renames a repository.
 150 	RenameRepository(oldName, newName string) error
 151-	// InitializeHooks initializes the hooks for the given repository.
 152-	InitializeHooks(repo string) error
 153 }
 154 
 155 // RepositoryMetadata is an interface for managing repository metadata.
 156diff --git a/server/backend/sqlite/hooks.go b/server/backend/sqlite/hooks.go
 157new file mode 100644
 158index 0000000000000000000000000000000000000000..cf82c2c009a3b72415d0ba09b72152521eabe823
 159--- /dev/null
 160+++ b/server/backend/sqlite/hooks.go
 161@@ -0,0 +1,64 @@
 162+package sqlite
 163+
 164+import (
 165+	"io"
 166+	"sync"
 167+
 168+	"github.com/charmbracelet/log"
 169+	"github.com/charmbracelet/soft-serve/server/backend"
 170+)
 171+
 172+// PostReceive is called by the git post-receive hook.
 173+//
 174+// It implements Hooks.
 175+func (d *SqliteBackend) PostReceive(stdout io.Writer, stderr io.Writer, repo string, args []backend.HookArg) {
 176+	log.WithPrefix("backend.sqlite.hooks").Debug("post-receive hook called", "repo", repo, "args", args)
 177+}
 178+
 179+// PreReceive is called by the git pre-receive hook.
 180+//
 181+// It implements Hooks.
 182+func (d *SqliteBackend) PreReceive(stdout io.Writer, stderr io.Writer, repo string, args []backend.HookArg) {
 183+	log.WithPrefix("backend.sqlite.hooks").Debug("pre-receive hook called", "repo", repo, "args", args)
 184+}
 185+
 186+// Update is called by the git update hook.
 187+//
 188+// It implements Hooks.
 189+func (d *SqliteBackend) Update(stdout io.Writer, stderr io.Writer, repo string, arg backend.HookArg) {
 190+	log.WithPrefix("backend.sqlite.hooks").Debug("update hook called", "repo", repo, "arg", arg)
 191+}
 192+
 193+// PostUpdate is called by the git post-update hook.
 194+//
 195+// It implements Hooks.
 196+func (d *SqliteBackend) PostUpdate(stdout io.Writer, stderr io.Writer, repo string, args ...string) {
 197+	log.WithPrefix("backend.sqlite.hooks").Debug("post-update hook called", "repo", repo, "args", args)
 198+
 199+	var wg sync.WaitGroup
 200+
 201+	// Update server info
 202+	wg.Add(1)
 203+	go func() {
 204+		defer wg.Done()
 205+
 206+		rr, err := d.Repository(repo)
 207+		if err != nil {
 208+			log.WithPrefix("backend.sqlite.hooks").Error("error getting repository", "repo", repo, "err", err)
 209+			return
 210+		}
 211+
 212+		r, err := rr.Open()
 213+		if err != nil {
 214+			log.WithPrefix("backend.sqlite.hooks").Error("error opening repository", "repo", repo, "err", err)
 215+			return
 216+		}
 217+
 218+		if err := r.UpdateServerInfo(); err != nil {
 219+			log.WithPrefix("backend.sqlite.hooks").Error("error updating server-info", "repo", repo, "err", err)
 220+			return
 221+		}
 222+	}()
 223+
 224+	wg.Wait()
 225+}
 226diff --git a/server/backend/sqlite/sqlite.go b/server/backend/sqlite/sqlite.go
 227index 382c99ff3e32005802de520ab5b6dcd273f05303..0720a09710bed98a2a3062482740ae3ddaafa050 100644
 228--- a/server/backend/sqlite/sqlite.go
 229+++ b/server/backend/sqlite/sqlite.go
 230@@ -1,18 +1,17 @@
 231 package sqlite
 232 
 233 import (
 234-	"bytes"
 235 	"context"
 236 	"fmt"
 237 	"os"
 238 	"path/filepath"
 239 	"strings"
 240-	"text/template"
 241 
 242 	"github.com/charmbracelet/log"
 243 	"github.com/charmbracelet/soft-serve/git"
 244 	"github.com/charmbracelet/soft-serve/server/backend"
 245 	"github.com/charmbracelet/soft-serve/server/config"
 246+	"github.com/charmbracelet/soft-serve/server/hooks"
 247 	"github.com/charmbracelet/soft-serve/server/utils"
 248 	"github.com/jmoiron/sqlx"
 249 	_ "modernc.org/sqlite"
 250@@ -165,7 +164,7 @@ func (d *SqliteBackend) CreateRepository(name string, opts backend.RepositoryOpt
 251 		db:   d.db,
 252 	}
 253 
 254-	return r, d.InitializeHooks(name)
 255+	return r, d.initRepo(name)
 256 }
 257 
 258 // ImportRepository imports a repository from remote.
 259@@ -186,7 +185,7 @@ func (d *SqliteBackend) ImportRepository(name string, remote string, opts backen
 260 			Envs: []string{
 261 				fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`,
 262 					filepath.Join(d.cfg.DataPath, "ssh", "known_hosts"),
 263-					d.cfg.Internal.ClientKeyPath,
 264+					d.cfg.SSH.ClientKeyPath,
 265 				),
 266 			},
 267 		},
 268@@ -551,157 +550,8 @@ func (d *SqliteBackend) RemoveCollaborator(repo string, username string) error {
 269 	)
 270 }
 271 
 272-var (
 273-	hookNames = []string{"pre-receive", "update", "post-update", "post-receive"}
 274-	hookTpls  = []string{
 275-		// for pre-receive
 276-		`#!/usr/bin/env bash
 277-# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
 278-data=$(cat)
 279-exitcodes=""
 280-hookname=$(basename $0)
 281-GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
 282-for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
 283-  test -x "${hook}" && test -f "${hook}" || continue
 284-  echo "${data}" | "${hook}"
 285-  exitcodes="${exitcodes} $?"
 286-done
 287-for i in ${exitcodes}; do
 288-  [ ${i} -eq 0 ] || exit ${i}
 289-done
 290-`,
 291-
 292-		// for update
 293-		`#!/usr/bin/env bash
 294-# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
 295-exitcodes=""
 296-hookname=$(basename $0)
 297-GIT_DIR=${GIT_DIR:-$(dirname $0/..)}
 298-for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
 299-  test -x "${hook}" && test -f "${hook}" || continue
 300-  "${hook}" $1 $2 $3
 301-  exitcodes="${exitcodes} $?"
 302-done
 303-for i in ${exitcodes}; do
 304-  [ ${i} -eq 0 ] || exit ${i}
 305-done
 306-`,
 307-
 308-		// for post-update
 309-		`#!/usr/bin/env bash
 310-# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
 311-data=$(cat)
 312-exitcodes=""
 313-hookname=$(basename $0)
 314-GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
 315-for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
 316-  test -x "${hook}" && test -f "${hook}" || continue
 317-  "${hook}" $@
 318-  exitcodes="${exitcodes} $?"
 319-done
 320-for i in ${exitcodes}; do
 321-  [ ${i} -eq 0 ] || exit ${i}
 322-done
 323-`,
 324-
 325-		// for post-receive
 326-		`#!/usr/bin/env bash
 327-# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
 328-data=$(cat)
 329-exitcodes=""
 330diff --git a/server/config/config.go b/server/config/config.go
 331index 7c598548430b8f846cea8d1e86bc75892ff44670..7bd696e0e0d18220f3726dea9d781635b6f24fec 100644
 332--- a/server/config/config.go
 333+++ b/server/config/config.go
 334@@ -25,6 +25,9 @@ type SSHConfig struct {
 335 	// KeyPath is the path to the SSH server's private key.
 336 	KeyPath string `env:"KEY_PATH" yaml:"key_path"`
 337 
 338+	// ClientKeyPath is the path to the server's client private key.
 339+	ClientKeyPath string `env:"CLIENT_KEY_PATH" yaml:"client_key_path"`
 340+
 341 	// MaxTimeout is the maximum number of seconds a connection can take.
 342 	MaxTimeout int `env:"MAX_TIMEOUT" yaml:"max_timeout"`
 343 
 344@@ -68,22 +71,6 @@ type StatsConfig struct {
 345 	ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
 346 }
 347 
 348-// InternalConfig is the configuration for the internal server.
 349-// This is used for internal communication between the Soft Serve client and server.
 350-type InternalConfig struct {
 351-	// ListenAddr is the address on which the internal server will listen.
 352-	ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
 353-
 354-	// KeyPath is the path to the SSH server's host private key.
 355-	KeyPath string `env:"KEY_PATH" yaml:"key_path"`
 356-
 357-	// InternalKeyPath is the path to the server's internal private key.
 358-	InternalKeyPath string `env:"INTERNAL_KEY_PATH" yaml:"internal_key_path"`
 359-
 360-	// ClientKeyPath is the path to the server's client private key.
 361-	ClientKeyPath string `env:"CLIENT_KEY_PATH" yaml:"client_key_path"`
 362-}
 363-
 364 // Config is the configuration for Soft Serve.
 365 type Config struct {
 366 	// Name is the name of the server.
 367@@ -101,9 +88,6 @@ type Config struct {
 368 	// Stats is the configuration for the stats server.
 369 	Stats StatsConfig `envPrefix:"STATS_" yaml:"stats"`
 370 
 371-	// Internal is the configuration for the internal server.
 372-	Internal InternalConfig `envPrefix:"INTERNAL_" yaml:"internal"`
 373-
 374 	// InitialAdminKeys is a list of public keys that will be added to the list of admins.
 375 	InitialAdminKeys []string `env:"INITIAL_ADMIN_KEYS" envSeparator:"\n" yaml:"initial_admin_keys"`
 376 
 377@@ -120,11 +104,12 @@ func parseConfig(path string) (*Config, error) {
 378 		Name:     "Soft Serve",
 379 		DataPath: dataPath,
 380 		SSH: SSHConfig{
 381-			ListenAddr:  ":23231",
 382-			PublicURL:   "ssh://localhost:23231",
 383-			KeyPath:     filepath.Join("ssh", "soft_serve_host_ed25519"),
 384-			MaxTimeout:  0,
 385-			IdleTimeout: 0,
 386+			ListenAddr:    ":23231",
 387+			PublicURL:     "ssh://localhost:23231",
 388+			KeyPath:       filepath.Join("ssh", "soft_serve_host_ed25519"),
 389+			ClientKeyPath: filepath.Join("ssh", "soft_serve_client_ed25519"),
 390+			MaxTimeout:    0,
 391+			IdleTimeout:   0,
 392 		},
 393 		Git: GitConfig{
 394 			ListenAddr:     ":9418",
 395@@ -139,12 +124,6 @@ func parseConfig(path string) (*Config, error) {
 396 		Stats: StatsConfig{
 397 			ListenAddr: "localhost:23233",
 398 		},
 399-		Internal: InternalConfig{
 400-			ListenAddr:      "localhost:23230",
 401-			KeyPath:         filepath.Join("ssh", "soft_serve_internal_host_ed25519"),
 402-			InternalKeyPath: filepath.Join("ssh", "soft_serve_internal_ed25519"),
 403-			ClientKeyPath:   filepath.Join("ssh", "soft_serve_client_ed25519"),
 404-		},
 405 	}
 406 
 407 	f, err := os.Open(path)
 408@@ -260,16 +239,8 @@ func (c *Config) validate() error {
 409 		c.SSH.KeyPath = filepath.Join(c.DataPath, c.SSH.KeyPath)
 410 	}
 411 
 412-	if c.Internal.KeyPath != "" && !filepath.IsAbs(c.Internal.KeyPath) {
 413-		c.Internal.KeyPath = filepath.Join(c.DataPath, c.Internal.KeyPath)
 414-	}
 415-
 416-	if c.Internal.ClientKeyPath != "" && !filepath.IsAbs(c.Internal.ClientKeyPath) {
 417-		c.Internal.ClientKeyPath = filepath.Join(c.DataPath, c.Internal.ClientKeyPath)
 418-	}
 419-
 420-	if c.Internal.InternalKeyPath != "" && !filepath.IsAbs(c.Internal.InternalKeyPath) {
 421-		c.Internal.InternalKeyPath = filepath.Join(c.DataPath, c.Internal.InternalKeyPath)
 422+	if c.SSH.ClientKeyPath != "" && !filepath.IsAbs(c.SSH.ClientKeyPath) {
 423+		c.SSH.ClientKeyPath = filepath.Join(c.DataPath, c.SSH.ClientKeyPath)
 424 	}
 425 
 426 	if c.HTTP.TLSKeyPath != "" && !filepath.IsAbs(c.HTTP.TLSKeyPath) {
 427@@ -298,7 +269,7 @@ func parseAuthKeys(aks []string) []ssh.PublicKey {
 428 	return pks
 429 }
 430 
 431-// AdminKeys returns the admin keys including the internal api key.
 432+// AdminKeys returns the server admin keys.
 433 func (c *Config) AdminKeys() []ssh.PublicKey {
 434diff --git a/server/config/file.go b/server/config/file.go
 435index eb462ae7061b7477ab999802b5cb9efa212eab11..1869dd87fcfd72aca7c71fb50cf6a19e49231f9e 100644
 436--- a/server/config/file.go
 437+++ b/server/config/file.go
 438@@ -24,6 +24,10 @@ ssh:
 439   # The path to the SSH server's private key.
 440   key_path: "{{ .SSH.KeyPath }}"
 441 
 442+  # The path to the server's client private key. This key will be used to
 443+  # authenticate the server to make git requests to ssh remotes.
 444+  client_key_path: "{{ .Internal.ClientKeyPath }}"
 445+
 446   # The maximum number of seconds a connection can take.
 447   # A value of 0 means no timeout.
 448   max_timeout: {{ .SSH.MaxTimeout }}
 449@@ -68,22 +72,6 @@ stats:
 450   # The address on which the stats server will listen.
 451   listen_addr: "{{ .Stats.ListenAddr }}"
 452 
 453-# The internal server configuration.
 454-internal:
 455-  # The address on which the internal server will listen.
 456-  listen_addr: "{{ .Internal.ListenAddr }}"
 457-
 458-  # The path to the Internal server's host private key.
 459-  key_path: "{{ .Internal.KeyPath }}"
 460-
 461-  # The path to the Internal server's client private key.
 462-  # This key will be used to authenticate the server to make git requests to
 463-  # ssh remotes.
 464-  client_key_path: "{{ .Internal.ClientKeyPath }}"
 465-
 466-  # The path to the Internal server's internal api private key.
 467-  internal_key_path: "{{ .Internal.InternalKeyPath }}"
 468-
 469 # Additional admin keys.
 470 #initial_admin_keys:
 471 #  - "ssh-rsa AAAAB3NzaC1yc2..."
 472diff --git a/server/daemon/daemon.go b/server/daemon/daemon.go
 473index 1a10c8c3bc204183e9362f74a0da948e8e816621..c3e08381502b6571fb4d3595627790680d053965 100644
 474--- a/server/daemon/daemon.go
 475+++ b/server/daemon/daemon.go
 476@@ -253,7 +253,13 @@ func (d *GitDaemon) handleClient(conn net.Conn) {
 477 			return
 478 		}
 479 
 480-		if err := gitPack(c, c, c, filepath.Join(reposDir, repo)); err != nil {
 481+		// Environment variables to pass down to git hooks.
 482+		envs := []string{
 483+			"SOFT_SERVE_REPO_NAME=" + name,
 484+			"SOFT_SERVE_REPO_PATH=" + filepath.Join(reposDir, repo),
 485+		}
 486+
 487+		if err := gitPack(ctx, c, c, c, filepath.Join(reposDir, repo), envs...); err != nil {
 488 			fatal(c, err)
 489 			return
 490 		}
 491diff --git a/server/git/git.go b/server/git/git.go
 492index 53c192d5468cbecf84ea8d740945e9cc8c0d5ce0..9a8b5ed9d401616151e7a47ced47a5ad9ff40dbc 100644
 493--- a/server/git/git.go
 494+++ b/server/git/git.go
 495@@ -1,16 +1,19 @@
 496 package git
 497 
 498 import (
 499+	"context"
 500 	"errors"
 501 	"fmt"
 502 	"io"
 503 	"os"
 504+	"os/exec"
 505 	"path/filepath"
 506 	"strings"
 507 
 508 	"github.com/charmbracelet/log"
 509 	"github.com/charmbracelet/soft-serve/git"
 510 	"github.com/go-git/go-git/v5/plumbing/format/pktline"
 511+	"golang.org/x/sync/errgroup"
 512 )
 513 
 514 var (
 515@@ -42,7 +45,7 @@ const (
 516 )
 517 
 518 // UploadPack runs the git upload-pack protocol against the provided repo.
 519-func UploadPack(in io.Reader, out io.Writer, er io.Writer, repoDir string) error {
 520+func UploadPack(ctx context.Context, in io.Reader, out io.Writer, er io.Writer, repoDir string, envs ...string) error {
 521 	exists, err := fileExists(repoDir)
 522 	if !exists {
 523 		return ErrInvalidRepo
 524@@ -50,11 +53,11 @@ func UploadPack(in io.Reader, out io.Writer, er io.Writer, repoDir string) error
 525 	if err != nil {
 526 		return err
 527 	}
 528-	return RunGit(in, out, er, "", UploadPackBin[4:], repoDir)
 529+	return RunGit(ctx, in, out, er, "", envs, UploadPackBin[4:], repoDir)
 530 }
 531 
 532 // UploadArchive runs the git upload-archive protocol against the provided repo.
 533-func UploadArchive(in io.Reader, out io.Writer, er io.Writer, repoDir string) error {
 534+func UploadArchive(ctx context.Context, in io.Reader, out io.Writer, er io.Writer, repoDir string, envs ...string) error {
 535 	exists, err := fileExists(repoDir)
 536 	if !exists {
 537 		return ErrInvalidRepo
 538@@ -62,25 +65,77 @@ func UploadArchive(in io.Reader, out io.Writer, er io.Writer, repoDir string) er
 539 	if err != nil {
 540 		return err
 541 	}
 542-	return RunGit(in, out, er, "", UploadArchiveBin[4:], repoDir)
 543+	return RunGit(ctx, in, out, er, "", envs, UploadArchiveBin[4:], repoDir)
 544 }
 545 
 546 // ReceivePack runs the git receive-pack protocol against the provided repo.
 547-func ReceivePack(in io.Reader, out io.Writer, er io.Writer, repoDir string) error {
 548-	if err := RunGit(in, out, er, "", ReceivePackBin[4:], repoDir); err != nil {
 549+func ReceivePack(ctx context.Context, in io.Reader, out io.Writer, er io.Writer, repoDir string, envs ...string) error {
 550+	if err := RunGit(ctx, in, out, er, "", envs, ReceivePackBin[4:], repoDir); err != nil {
 551 		return err
 552 	}
 553-	return EnsureDefaultBranch(in, out, er, repoDir)
 554+	return EnsureDefaultBranch(ctx, in, out, er, repoDir)
 555 }
 556 
 557 // RunGit runs a git command in the given repo.
 558-func RunGit(in io.Reader, out io.Writer, err io.Writer, dir string, args ...string) error {
 559-	c := git.NewCommand(args...)
 560-	return c.RunInDirWithOptions(dir, git.RunInDirOptions{
 561-		Stdin:  in,
 562-		Stdout: out,
 563-		Stderr: err,
 564+func RunGit(ctx context.Context, in io.Reader, out io.Writer, er io.Writer, dir string, envs []string, args ...string) error {
 565+	logger := log.WithPrefix("server.git")
 566+	c := exec.CommandContext(ctx, "git", args...)
 567+	c.Dir = dir
 568+	c.Env = append(c.Env, envs...)
 569+	c.Env = append(c.Env, "SOFT_SERVE_DEBUG="+os.Getenv("SOFT_SERVE_DEBUG"))
 570+	c.Env = append(c.Env, "PATH="+os.Getenv("PATH"))
 571+
 572+	stdin, err := c.StdinPipe()
 573+	if err != nil {
 574+		logger.Error("failed to get stdin pipe", "err", err)
 575+		return err
 576+	}
 577+
 578+	stdout, err := c.StdoutPipe()
 579+	if err != nil {
 580+		logger.Error("failed to get stdout pipe", "err", err)
 581+		return err
 582+	}
 583+
 584+	stderr, err := c.StderrPipe()
 585+	if err != nil {
 586+		logger.Error("failed to get stderr pipe", "err", err)
 587+		return err
 588+	}
 589+
 590+	if err := c.Start(); err != nil {
 591+		logger.Error("failed to start command", "err", err)
 592+		return err
 593+	}
 594+
 595diff --git a/server/hooks.go b/server/hooks.go
 596deleted file mode 100644
 597index 17cc4c83e108cd1f24e4e1f72c1b413d95dd294b..0000000000000000000000000000000000000000
 598--- a/server/hooks.go
 599+++ /dev/null
 600@@ -1,54 +0,0 @@
 601-package server
 602-
 603-import (
 604-	"io"
 605-
 606-	"github.com/charmbracelet/log"
 607-	"github.com/charmbracelet/soft-serve/server/hooks"
 608-)
 609-
 610-var _ hooks.Hooks = (*Server)(nil)
 611-
 612-// PostReceive is called by the git post-receive hook.
 613-//
 614-// It implements Hooks.
 615-func (*Server) PostReceive(stdin io.Reader, stdout io.Writer, stderr io.Writer, repo string, args []hooks.HookArg) {
 616-	log.WithPrefix("server.hooks").Debug("post-receive hook called", "repo", repo, "args", args)
 617-}
 618-
 619-// PreReceive is called by the git pre-receive hook.
 620-//
 621-// It implements Hooks.
 622-func (*Server) PreReceive(stdin io.Reader, stdout io.Writer, stderr io.Writer, repo string, args []hooks.HookArg) {
 623-	log.WithPrefix("server.hooks").Debug("pre-receive hook called", "repo", repo, "args", args)
 624-}
 625-
 626-// Update is called by the git update hook.
 627-//
 628-// It implements Hooks.
 629-func (*Server) Update(stdin io.Reader, stdout io.Writer, stderr io.Writer, repo string, arg hooks.HookArg) {
 630-	log.WithPrefix("server.hooks").Debug("update hook called", "repo", repo, "arg", arg)
 631-}
 632-
 633-// PostUpdate is called by the git post-update hook.
 634-//
 635-// It implements Hooks.
 636-func (s *Server) PostUpdate(stdin io.Reader, stdout io.Writer, stderr io.Writer, repo string, args ...string) {
 637-	log.WithPrefix("server.hooks").Debug("post-update hook called", "repo", repo, "args", args)
 638-	rr, err := s.Config.Backend.Repository(repo)
 639-	if err != nil {
 640-		log.WithPrefix("server.hooks.post-update").Error("error getting repository", "repo", repo, "err", err)
 641-		return
 642-	}
 643-
 644-	r, err := rr.Open()
 645-	if err != nil {
 646-		log.WithPrefix("server.hooks.post-update").Error("error opening repository", "repo", repo, "err", err)
 647-		return
 648-	}
 649-
 650-	if err := r.UpdateServerInfo(); err != nil {
 651-		log.WithPrefix("server.hooks.post-update").Error("error updating server info", "repo", repo, "err", err)
 652-		return
 653-	}
 654-}
 655diff --git a/server/hooks/hooks.go b/server/hooks/hooks.go
 656index 639950413741d4cebd47eebfcffc47b0e5107303..f7606bdd599d66ffcc0bb4cac24684fb1b1a3ca8 100644
 657--- a/server/hooks/hooks.go
 658+++ b/server/hooks/hooks.go
 659@@ -1,18 +1,152 @@
 660 package hooks
 661 
 662-import "io"
 663+import (
 664+	"bytes"
 665+	"context"
 666+	"fmt"
 667+	"os"
 668+	"path/filepath"
 669+	"text/template"
 670 
 671-// HookArg is an argument to a git hook.
 672-type HookArg struct {
 673-	OldSha  string
 674-	NewSha  string
 675-	RefName string
 676-}
 677+	"github.com/charmbracelet/log"
 678+	"github.com/charmbracelet/soft-serve/server/config"
 679+	"github.com/charmbracelet/soft-serve/server/utils"
 680+)
 681+
 682+// The names of git server-side hooks.
 683+const (
 684+	PreReceiveHook  = "pre-receive"
 685+	UpdateHook      = "update"
 686+	PostReceiveHook = "post-receive"
 687+	PostUpdateHook  = "post-update"
 688+)
 689+
 690+// GenerateHooks generates git server-side hooks for a repository. Currently, it supports the following hooks:
 691+// - pre-receive
 692+// - update
 693+// - post-receive
 694+// - post-update
 695+//
 696+// This function should be called by the backend when a repository is created.
 697+// TODO: support context
 698+func GenerateHooks(ctx context.Context, cfg *config.Config, repo string) error {
 699+	repo = utils.SanitizeRepo(repo) + ".git"
 700+	hooksPath := filepath.Join(cfg.DataPath, "repos", repo, "hooks")
 701+	if err := os.MkdirAll(hooksPath, os.ModePerm); err != nil {
 702+		return err
 703+	}
 704+
 705+	ex, err := os.Executable()
 706+	if err != nil {
 707+		return err
 708+	}
 709+
 710+	dp, err := filepath.Abs(cfg.DataPath)
 711+	if err != nil {
 712+		return fmt.Errorf("failed to get absolute path for data path: %w", err)
 713+	}
 714+
 715+	cp := filepath.Join(dp, "config.yaml")
 716+	// Add extra environment variables to the hooks here.
 717+	envs := []string{}
 718+
 719+	for _, hook := range []string{
 720+		PreReceiveHook,
 721+		UpdateHook,
 722+		PostReceiveHook,
 723+		PostUpdateHook,
 724+	} {
 725+		var data bytes.Buffer
 726+		var args string
 727+
 728+		// Hooks script/directory path
 729+		hp := filepath.Join(hooksPath, hook)
 730+
 731+		// Write the hooks primary script
 732+		if err := os.WriteFile(hp, []byte(hookTemplate), os.ModePerm); err != nil {
 733+			return err
 734+		}
 735 
 736-// Hooks provides an interface for git server-side hooks.
 737-type Hooks interface {
 738-	PreReceive(stdin io.Reader, stdout io.Writer, stderr io.Writer, repo string, args []HookArg)
 739-	Update(stdin io.Reader, stdout io.Writer, stderr io.Writer, repo string, arg HookArg)
 740-	PostReceive(stdin io.Reader, stdout io.Writer, stderr io.Writer, repo string, args []HookArg)
 741-	PostUpdate(stdin io.Reader, stdout io.Writer, stderr io.Writer, repo string, args ...string)
 742+		// Create ${hook}.d directory.
 743+		hp += ".d"
 744+		if err := os.MkdirAll(hp, os.ModePerm); err != nil {
 745+			return err
 746+		}
 747+
 748+		switch hook {
 749+		case UpdateHook:
 750+			args = "$1 $2 $3"
 751+		case PostUpdateHook:
 752+			args = "$@"
 753+		}
 754+
 755+		if err := hooksTmpl.Execute(&data, struct {
 756+			Executable string
 757+			Config     string
 758+			Envs       []string
 759diff --git a/server/internal/cmd.go b/server/internal/cmd.go
 760deleted file mode 100644
 761index 62709bed11248fa73e540decbb53ca03a437921e..0000000000000000000000000000000000000000
 762--- a/server/internal/cmd.go
 763+++ /dev/null
 764@@ -1,84 +0,0 @@
 765-package internal
 766-
 767-import (
 768-	"context"
 769-
 770-	"github.com/charmbracelet/soft-serve/server/config"
 771-	"github.com/charmbracelet/soft-serve/server/hooks"
 772-	"github.com/charmbracelet/ssh"
 773-	"github.com/charmbracelet/wish"
 774-	"github.com/spf13/cobra"
 775-)
 776-
 777-var (
 778-	hooksCtxKey   = "hooks"
 779-	sessionCtxKey = "session"
 780-	configCtxKey  = "config"
 781-)
 782-
 783-// rootCommand is the root command for the server.
 784-func rootCommand(cfg *config.Config, s ssh.Session) *cobra.Command {
 785-	rootCmd := &cobra.Command{
 786-		Short:        "Soft Serve internal API.",
 787-		SilenceUsage: true,
 788-	}
 789-
 790-	rootCmd.SetIn(s)
 791-	rootCmd.SetOut(s)
 792-	rootCmd.SetErr(s)
 793-	rootCmd.CompletionOptions.DisableDefaultCmd = true
 794-
 795-	rootCmd.AddCommand(
 796-		hookCommand(),
 797-	)
 798-
 799-	return rootCmd
 800-}
 801-
 802-// Middleware returns the middleware for the server.
 803-func (i *InternalServer) Middleware(hooks hooks.Hooks) wish.Middleware {
 804-	return func(sh ssh.Handler) ssh.Handler {
 805-		return func(s ssh.Session) {
 806-			_, _, active := s.Pty()
 807-			if active {
 808-				return
 809-			}
 810-
 811-			// Ignore git server commands.
 812-			args := s.Command()
 813-			if len(args) > 0 {
 814-				if args[0] == "git-receive-pack" ||
 815-					args[0] == "git-upload-pack" ||
 816-					args[0] == "git-upload-archive" {
 817-					return
 818-				}
 819-			}
 820-
 821-			ctx := context.WithValue(s.Context(), hooksCtxKey, hooks)
 822-			ctx = context.WithValue(ctx, sessionCtxKey, s)
 823-			ctx = context.WithValue(ctx, configCtxKey, i.cfg)
 824-
 825-			rootCmd := rootCommand(i.cfg, s)
 826-			rootCmd.SetArgs(args)
 827-			if len(args) == 0 {
 828-				// otherwise it'll default to os.Args, which is not what we want.
 829-				rootCmd.SetArgs([]string{"--help"})
 830-			}
 831-			rootCmd.SetIn(s)
 832-			rootCmd.SetOut(s)
 833-			rootCmd.CompletionOptions.DisableDefaultCmd = true
 834-			rootCmd.SetErr(s.Stderr())
 835-			if err := rootCmd.ExecuteContext(ctx); err != nil {
 836-				_ = s.Exit(1)
 837-			}
 838-			sh(s)
 839-		}
 840-	}
 841-}
 842-
 843-func fromContext(cmd *cobra.Command) (*config.Config, ssh.Session) {
 844-	ctx := cmd.Context()
 845-	cfg := ctx.Value(configCtxKey).(*config.Config)
 846-	s := ctx.Value(sessionCtxKey).(ssh.Session)
 847-	return cfg, s
 848-}
 849diff --git a/server/internal/hook.go b/server/internal/hook.go
 850deleted file mode 100644
 851index a4d4ecbaf7254f85ea798f0026009b8154724ba7..0000000000000000000000000000000000000000
 852--- a/server/internal/hook.go
 853+++ /dev/null
 854@@ -1,138 +0,0 @@
 855-package internal
 856-
 857-import (
 858-	"bufio"
 859-	"fmt"
 860-	"strings"
 861-
 862-	"github.com/charmbracelet/keygen"
 863-	"github.com/charmbracelet/log"
 864-	"github.com/charmbracelet/soft-serve/server/backend"
 865-	"github.com/charmbracelet/soft-serve/server/errors"
 866-	"github.com/charmbracelet/soft-serve/server/hooks"
 867-	"github.com/charmbracelet/ssh"
 868-	"github.com/spf13/cobra"
 869-)
 870-
 871-// hookCommand handles Soft Serve internal API git hook requests.
 872-func hookCommand() *cobra.Command {
 873-	preReceiveCmd := &cobra.Command{
 874-		Use:   "pre-receive",
 875-		Short: "Run git pre-receive hook",
 876-		RunE: func(cmd *cobra.Command, args []string) error {
 877-			_, s := fromContext(cmd)
 878-			hks := cmd.Context().Value(hooksCtxKey).(hooks.Hooks)
 879-			repoName := getRepoName(s)
 880-			opts := make([]hooks.HookArg, 0)
 881-			scanner := bufio.NewScanner(s)
 882-			for scanner.Scan() {
 883-				fields := strings.Fields(scanner.Text())
 884-				if len(fields) != 3 {
 885-					return fmt.Errorf("invalid pre-receive hook input: %s", scanner.Text())
 886-				}
 887-				opts = append(opts, hooks.HookArg{
 888-					OldSha:  fields[0],
 889-					NewSha:  fields[1],
 890-					RefName: fields[2],
 891-				})
 892-			}
 893-			hks.PreReceive(s, s, s.Stderr(), repoName, opts)
 894-			return nil
 895-		},
 896-	}
 897-
 898-	updateCmd := &cobra.Command{
 899-		Use:   "update",
 900-		Short: "Run git update hook",
 901-		Args:  cobra.ExactArgs(3),
 902-		RunE: func(cmd *cobra.Command, args []string) error {
 903-			_, s := fromContext(cmd)
 904-			hks := cmd.Context().Value(hooksCtxKey).(hooks.Hooks)
 905-			repoName := getRepoName(s)
 906-			hks.Update(s, s, s.Stderr(), repoName, hooks.HookArg{
 907-				RefName: args[0],
 908-				OldSha:  args[1],
 909-				NewSha:  args[2],
 910-			})
 911-			return nil
 912-		},
 913-	}
 914-
 915-	postReceiveCmd := &cobra.Command{
 916-		Use:   "post-receive",
 917-		Short: "Run git post-receive hook",
 918-		RunE: func(cmd *cobra.Command, _ []string) error {
 919-			_, s := fromContext(cmd)
 920-			hks := cmd.Context().Value(hooksCtxKey).(hooks.Hooks)
 921-			repoName := getRepoName(s)
 922-			opts := make([]hooks.HookArg, 0)
 923-			scanner := bufio.NewScanner(s)
 924-			for scanner.Scan() {
 925-				fields := strings.Fields(scanner.Text())
 926-				if len(fields) != 3 {
 927-					return fmt.Errorf("invalid post-receive hook input: %s", scanner.Text())
 928-				}
 929-				opts = append(opts, hooks.HookArg{
 930-					OldSha:  fields[0],
 931-					NewSha:  fields[1],
 932-					RefName: fields[2],
 933-				})
 934-			}
 935-			hks.PostReceive(s, s, s.Stderr(), repoName, opts)
 936-			return nil
 937-		},
 938-	}
 939-
 940-	postUpdateCmd := &cobra.Command{
 941-		Use:   "post-update",
 942-		Short: "Run git post-update hook",
 943-		RunE: func(cmd *cobra.Command, args []string) error {
 944-			_, s := fromContext(cmd)
 945-			hks := cmd.Context().Value(hooksCtxKey).(hooks.Hooks)
 946-			repoName := getRepoName(s)
 947-			hks.PostUpdate(s, s, s.Stderr(), repoName, args...)
 948-			return nil
 949-		},
 950-	}
 951-
 952-	hookCmd := &cobra.Command{
 953-		Use:          "hook",
 954diff --git a/server/internal/internal.go b/server/internal/internal.go
 955deleted file mode 100644
 956index 52114c4ca0afbfdc2fd1da7fa81d914c68139777..0000000000000000000000000000000000000000
 957--- a/server/internal/internal.go
 958+++ /dev/null
 959@@ -1,86 +0,0 @@
 960-package internal
 961-
 962-import (
 963-	"context"
 964-	"fmt"
 965-
 966-	"github.com/charmbracelet/keygen"
 967-	"github.com/charmbracelet/soft-serve/server/backend"
 968-	"github.com/charmbracelet/soft-serve/server/config"
 969-	"github.com/charmbracelet/soft-serve/server/hooks"
 970-	"github.com/charmbracelet/ssh"
 971-	"github.com/charmbracelet/wish"
 972-)
 973-
 974-// InternalServer is a internal interface to communicate with the server.
 975-type InternalServer struct {
 976-	cfg *config.Config
 977-	s   *ssh.Server
 978-	kp  *keygen.SSHKeyPair
 979-	ckp *keygen.SSHKeyPair
 980-}
 981-
 982-// NewInternalServer returns a new internal server.
 983-func NewInternalServer(cfg *config.Config, hooks hooks.Hooks) (*InternalServer, error) {
 984-	i := &InternalServer{cfg: cfg}
 985-
 986-	// Create internal key.
 987-	ikp, err := keygen.New(
 988-		cfg.Internal.InternalKeyPath,
 989-		keygen.WithKeyType(keygen.Ed25519),
 990-		keygen.WithWrite(),
 991-	)
 992-	if err != nil {
 993-		return nil, fmt.Errorf("internal key: %w", err)
 994-	}
 995-
 996-	i.kp = ikp
 997-
 998-	// Create client key.
 999-	ckp, err := keygen.New(
1000-		cfg.Internal.ClientKeyPath,
1001-		keygen.WithKeyType(keygen.Ed25519),
1002-		keygen.WithWrite(),
1003-	)
1004-	if err != nil {
1005-		return nil, fmt.Errorf("client key: %w", err)
1006-	}
1007-
1008-	i.ckp = ckp
1009-
1010-	s, err := wish.NewServer(
1011-		wish.WithAddress(cfg.Internal.ListenAddr),
1012-		wish.WithHostKeyPath(cfg.Internal.KeyPath),
1013-		wish.WithPublicKeyAuth(i.PublicKeyHandler),
1014-		wish.WithMiddleware(
1015-			i.Middleware(hooks),
1016-		),
1017-	)
1018-	if err != nil {
1019-		return nil, fmt.Errorf("wish: %w", err)
1020-	}
1021-
1022-	i.s = s
1023-
1024-	return i, nil
1025-}
1026-
1027-// PublicKeyHandler handles public key authentication.
1028-func (i *InternalServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) bool {
1029-	return backend.KeysEqual(i.kp.PublicKey(), pk)
1030-}
1031-
1032-// Start starts the internal server.
1033-func (i *InternalServer) Start() error {
1034-	return i.s.ListenAndServe()
1035-}
1036-
1037-// Shutdown shuts down the internal server.
1038-func (i *InternalServer) Shutdown(ctx context.Context) error {
1039-	return i.s.Shutdown(ctx)
1040-}
1041-
1042-// Close closes the internal server.
1043-func (i *InternalServer) Close() error {
1044-	return i.s.Close()
1045-}
1046diff --git a/server/jobs.go b/server/jobs.go
1047index 37ecbeb36a33c540a38bfd5ace33615f0cce05ce..5f239be583961649353568d5048042e497f8bfb6 100644
1048--- a/server/jobs.go
1049+++ b/server/jobs.go
1050@@ -38,7 +38,7 @@ func mirrorJob(cfg *config.Config) func() {
1051 				cmd.AddEnvs(
1052 					fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`,
1053 						filepath.Join(cfg.DataPath, "ssh", "known_hosts"),
1054-						cfg.Internal.ClientKeyPath,
1055+						cfg.SSH.ClientKeyPath,
1056 					),
1057 				)
1058 				if _, err := cmd.RunInDir(r.Path); err != nil {
1059diff --git a/server/server.go b/server/server.go
1060index ecc2c7c357897d71adc4610fb252ca357a291470..182b20f6662cb49c3a8655f84b5b06d806f047cd 100644
1061--- a/server/server.go
1062+++ b/server/server.go
1063@@ -13,7 +13,6 @@ import (
1064 	"github.com/charmbracelet/soft-serve/server/config"
1065 	"github.com/charmbracelet/soft-serve/server/cron"
1066 	"github.com/charmbracelet/soft-serve/server/daemon"
1067-	"github.com/charmbracelet/soft-serve/server/internal"
1068 	sshsrv "github.com/charmbracelet/soft-serve/server/ssh"
1069 	"github.com/charmbracelet/soft-serve/server/stats"
1070 	"github.com/charmbracelet/soft-serve/server/web"
1071@@ -27,15 +26,14 @@ var (
1072 
1073 // Server is the Soft Serve server.
1074 type Server struct {
1075-	SSHServer      *sshsrv.SSHServer
1076-	GitDaemon      *daemon.GitDaemon
1077-	HTTPServer     *web.HTTPServer
1078-	StatsServer    *stats.StatsServer
1079-	InternalServer *internal.InternalServer
1080-	Cron           *cron.CronScheduler
1081-	Config         *config.Config
1082-	Backend        backend.Backend
1083-	ctx            context.Context
1084+	SSHServer   *sshsrv.SSHServer
1085+	GitDaemon   *daemon.GitDaemon
1086+	HTTPServer  *web.HTTPServer
1087+	StatsServer *stats.StatsServer
1088+	Cron        *cron.CronScheduler
1089+	Config      *config.Config
1090+	Backend     backend.Backend
1091+	ctx         context.Context
1092 }
1093 
1094 // NewServer returns a new *ssh.Server configured to serve Soft Serve. The SSH
1095@@ -84,11 +82,6 @@ func NewServer(ctx context.Context, cfg *config.Config) (*Server, error) {
1096 		return nil, fmt.Errorf("create stats server: %w", err)
1097 	}
1098 
1099-	srv.InternalServer, err = internal.NewInternalServer(cfg, srv)
1100-	if err != nil {
1101-		return nil, fmt.Errorf("create internal server: %w", err)
1102-	}
1103-
1104 	return srv, nil
1105 }
1106 
1107@@ -143,13 +136,6 @@ func (s *Server) Start() error {
1108 		s.Cron.Start()
1109 		return nil
1110 	})
1111-	errg.Go(func() error {
1112-		logger.Print("Starting internal server", "addr", s.Config.Internal.ListenAddr)
1113-		if err := start(ctx, s.InternalServer.Start); !errors.Is(err, http.ErrServerClosed) {
1114-			return err
1115-		}
1116-		return nil
1117-	})
1118 	return errg.Wait()
1119 }
1120 
1121@@ -172,9 +158,6 @@ func (s *Server) Shutdown(ctx context.Context) error {
1122 		s.Cron.Stop()
1123 		return nil
1124 	})
1125-	errg.Go(func() error {
1126-		return s.InternalServer.Shutdown(ctx)
1127-	})
1128 	return errg.Wait()
1129 }
1130 
1131@@ -189,6 +172,5 @@ func (s *Server) Close() error {
1132 		s.Cron.Stop()
1133 		return nil
1134 	})
1135-	errg.Go(s.InternalServer.Close)
1136 	return errg.Wait()
1137 }
1138diff --git a/server/ssh/ssh.go b/server/ssh/ssh.go
1139index a407d8fc929541f26ff18baba0e511163a247775..419ab241d2e335f8b6c4ca8ae6330177c77dce4f 100644
1140--- a/server/ssh/ssh.go
1141+++ b/server/ssh/ssh.go
1142@@ -189,6 +189,13 @@ func (s *SSHServer) Middleware(cfg *config.Config) wish.Middleware {
1143 						return
1144 					}
1145 
1146+					// Environment variables to pass down to git hooks.
1147+					envs := []string{
1148+						"SOFT_SERVE_REPO_NAME=" + name,
1149+						"SOFT_SERVE_REPO_PATH=" + filepath.Join(reposDir, repo),
1150+						"SOFT_SERVE_PUBLIC_KEY=" + ak,
1151+					}
1152+
1153 					logger.Debug("git middleware", "cmd", gc, "access", access.String())
1154 					repoDir := filepath.Join(reposDir, repo)
1155 					switch gc {
1156@@ -205,7 +212,7 @@ func (s *SSHServer) Middleware(cfg *config.Config) wish.Middleware {
1157 							}
1158 							createRepoCounter.WithLabelValues(ak, s.User(), name).Inc()
1159 						}
1160-						if err := git.ReceivePack(s, s, s.Stderr(), repoDir); err != nil {
1161+						if err := git.ReceivePack(s.Context(), s, s, s.Stderr(), repoDir, envs...); err != nil {
1162 							sshFatal(s, git.ErrSystemMalfunction)
1163 						}
1164 						receivePackCounter.WithLabelValues(ak, s.User(), name).Inc()
1165@@ -223,7 +230,7 @@ func (s *SSHServer) Middleware(cfg *config.Config) wish.Middleware {
1166 							counter = uploadArchiveCounter
1167 						}
1168 
1169-						err := gitPack(s, s, s.Stderr(), repoDir)
1170+						err := gitPack(s.Context(), s, s, s.Stderr(), repoDir, envs...)
1171 						if errors.Is(err, git.ErrInvalidRepo) {
1172 							sshFatal(s, git.ErrInvalidRepo)
1173 						} else if err != nil {
1174diff --git a/server/test/test.go b/server/test/test.go
1175index 2dfe85c97dea227186565a04ef3ad86b31e120f8..a9d4d0eca7ac48899385dd23254c66f1249e2855 100644
1176--- a/server/test/test.go
1177+++ b/server/test/test.go
1178@@ -2,6 +2,8 @@ package test
1179 
1180 import "net"
1181 
1182+// RandomPort returns a random port number.
1183+// This is mainly used for testing.
1184 func RandomPort() int {
1185 	addr, _ := net.Listen("tcp", ":0") //nolint:gosec
1186 	_ = addr.Close()