227f178ad2ca13ae909065cce734d77172664930

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

Message

feat(server): add git hooks

Diff

This diff is truncated to protect this page.

  1diff --git a/cmd/soft/hook.go b/cmd/soft/hook.go
  2new file mode 100644
  3index 0000000000000000000000000000000000000000..ec51aac5010744696e3c03a9c10bd0f9ee763a14
  4--- /dev/null
  5+++ b/cmd/soft/hook.go
  6@@ -0,0 +1,215 @@
  7+package main
  8+
  9+import (
 10+	"bufio"
 11+	"fmt"
 12+	"os"
 13+	"path/filepath"
 14+	"strings"
 15+
 16+	"github.com/charmbracelet/keygen"
 17+	"github.com/charmbracelet/soft-serve/server/config"
 18+	"github.com/spf13/cobra"
 19+	gossh "golang.org/x/crypto/ssh"
 20+)
 21+
 22+var (
 23+	configPath string
 24+
 25+	hookCmd = &cobra.Command{
 26+		Use:    "hook",
 27+		Short:  "Run git server hooks",
 28+		Long:   "Handles git server hooks. This includes pre-receive, update, and post-receive.",
 29+		Hidden: true,
 30+	}
 31+
 32+	preReceiveCmd = &cobra.Command{
 33+		Use:   "pre-receive",
 34+		Short: "Run git pre-receive hook",
 35+		RunE: func(cmd *cobra.Command, args []string) error {
 36+			c, s, err := commonInit()
 37+			if err != nil {
 38+				return err
 39+			}
 40+			defer c.Close() //nolint:errcheck
 41+			defer s.Close() //nolint:errcheck
 42+			in, err := s.StdinPipe()
 43+			if err != nil {
 44+				return err
 45+			}
 46+			scanner := bufio.NewScanner(os.Stdin)
 47+			for scanner.Scan() {
 48+				in.Write([]byte(scanner.Text()))
 49+				in.Write([]byte("\n"))
 50+			}
 51+			in.Close() //nolint:errcheck
 52+			b, err := s.Output("hook pre-receive")
 53+			if err != nil {
 54+				return err
 55+			}
 56+			cmd.Print(string(b))
 57+			return nil
 58+		},
 59+	}
 60+
 61+	updateCmd = &cobra.Command{
 62+		Use:   "update",
 63+		Short: "Run git update hook",
 64+		Args:  cobra.ExactArgs(3),
 65+		RunE: func(cmd *cobra.Command, args []string) error {
 66+			refName := args[0]
 67+			oldSha := args[1]
 68+			newSha := args[2]
 69+			c, s, err := commonInit()
 70+			if err != nil {
 71+				return err
 72+			}
 73+			defer c.Close() //nolint:errcheck
 74+			defer s.Close() //nolint:errcheck
 75+			b, err := s.Output(fmt.Sprintf("hook update %s %s %s", refName, oldSha, newSha))
 76+			if err != nil {
 77+				return err
 78+			}
 79+			cmd.Print(string(b))
 80+			return nil
 81+		},
 82+	}
 83+
 84+	postReceiveCmd = &cobra.Command{
 85+		Use:   "post-receive",
 86+		Short: "Run git post-receive hook",
 87+		RunE: func(cmd *cobra.Command, args []string) error {
 88+			c, s, err := commonInit()
 89+			if err != nil {
 90+				return err
 91+			}
 92+			defer c.Close() //nolint:errcheck
 93+			defer s.Close() //nolint:errcheck
 94+			in, err := s.StdinPipe()
 95+			if err != nil {
 96+				return err
 97+			}
 98+			scanner := bufio.NewScanner(os.Stdin)
 99+			for scanner.Scan() {
100+				in.Write([]byte(scanner.Text()))
101+				in.Write([]byte("\n"))
102+			}
103+			in.Close() //nolint:errcheck
104+			b, err := s.Output("hook post-receive")
105+			if err != nil {
106diff --git a/cmd/soft/root.go b/cmd/soft/root.go
107index 1f4cbeacdff6e3eb2c343f4048b79f1358eb8983..fe7cb6540a8028bda78d65df29b1a1bf46b02071 100644
108--- a/cmd/soft/root.go
109+++ b/cmd/soft/root.go
110@@ -31,6 +31,7 @@ func init() {
111 	rootCmd.AddCommand(
112 		serveCmd,
113 		manCmd,
114+		hookCmd,
115 	)
116 	rootCmd.CompletionOptions.HiddenDefaultCmd = true
117 
118diff --git a/go.mod b/go.mod
119index f2088542e92c527d1979083e70d2c4994907d09f..ea287b8a72ecb35b94c9858d32a5504ade2c8c1e 100644
120--- a/go.mod
121+++ b/go.mod
122@@ -32,6 +32,7 @@ require (
123 	goji.io v2.0.2+incompatible
124 	golang.org/x/crypto v0.7.0
125 	golang.org/x/sync v0.1.0
126+	gopkg.in/yaml.v3 v3.0.1
127 )
128 
129 require (
130diff --git a/server/backend/file/file.go b/server/backend/file/file.go
131index aa0465b3f44b64c8d7f04f63ccab5ab8cfcf1e55..9f855db570573db36b0b313e8ce6addf49d877a6 100644
132--- a/server/backend/file/file.go
133+++ b/server/backend/file/file.go
134@@ -20,8 +20,10 @@ package file
135 
136 import (
137 	"bufio"
138+	"bytes"
139 	"errors"
140 	"fmt"
141+	"html/template"
142 	"io"
143 	"io/fs"
144 	"os"
145@@ -584,18 +586,31 @@ func (fb *FileBackend) CreateRepository(repo string, private bool) (backend.Repo
146 		return nil, os.ErrExist
147 	}
148 
149-	if _, err := git.Init(rp, true); err != nil {
150+	rr, err := git.Init(rp, true)
151+	if err != nil {
152 		logger.Debug("failed to create repository", "err", err)
153 		return nil, err
154 	}
155 
156-	fb.SetPrivate(repo, private)
157-	fb.SetDescription(repo, "")
158+	if err := rr.UpdateServerInfo(); err != nil {
159+		logger.Debug("failed to update server info", "err", err)
160+		return nil, err
161+	}
162+
163+	if err := fb.SetPrivate(repo, private); err != nil {
164+		logger.Debug("failed to set private status", "err", err)
165+		return nil, err
166+	}
167+
168+	if err := fb.SetDescription(repo, ""); err != nil {
169+		logger.Debug("failed to set description", "err", err)
170+		return nil, err
171+	}
172 
173 	r := &Repo{path: rp, root: fb.reposPath()}
174 	// Add to cache.
175 	fb.repos[name] = r
176-	return r, nil
177+	return r, fb.InitializeHooks(name)
178 }
179 
180 // DeleteRepository deletes the given repository.
181@@ -687,6 +702,9 @@ func (fb *FileBackend) initRepos() error {
182 			r := &Repo{path: path, root: fb.reposPath()}
183 			fb.repos[r.Name()] = r
184 			repos = append(repos, r)
185+			if err := fb.InitializeHooks(r.Name()); err != nil {
186+				logger.Warn("failed to initialize hooks", "err", err, "repo", r.Name())
187+			}
188 		}
189 
190 		return nil
191@@ -709,3 +727,156 @@ func (fb *FileBackend) Repositories() ([]backend.Repository, error) {
192 
193 	return repos, nil
194 }
195+
196+var (
197+	hookNames = []string{"pre-receive", "update", "post-update", "post-receive"}
198+	hookTpls  = []string{
199+		// for pre-receive
200+		`#!/usr/bin/env bash
201+# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
202+data=$(cat)
203+exitcodes=""
204+hookname=$(basename $0)
205+GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
206+for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
207+  test -x "${hook}" && test -f "${hook}" || continue
208+  echo "${data}" | "${hook}"
209+  exitcodes="${exitcodes} $?"
210+done
211+for i in ${exitcodes}; do
212+  [ ${i} -eq 0 ] || exit ${i}
213+done
214+`,
215+
216+		// for update
217+		`#!/usr/bin/env bash
218+# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
219+exitcodes=""
220+hookname=$(basename $0)
221+GIT_DIR=${GIT_DIR:-$(dirname $0/..)}
222+for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
223+  test -x "${hook}" && test -f "${hook}" || continue
224+  "${hook}" $1 $2 $3
225+  exitcodes="${exitcodes} $?"
226+done
227+for i in ${exitcodes}; do
228+  [ ${i} -eq 0 ] || exit ${i}
229+done
230+`,
231+
232+		// for post-update
233+		`#!/usr/bin/env bash
234diff --git a/server/cmd/blob.go b/server/cmd/blob.go
235index 58e2361f9342c32d1c482b8b2f5ad2031104c00f..187ee07b21d60a804ebde4c19af408e4b3ee30b9 100644
236--- a/server/cmd/blob.go
237+++ b/server/cmd/blob.go
238@@ -30,7 +30,7 @@ func blobCommand() *cobra.Command {
239 	cmd := &cobra.Command{
240 		Use:               "blob REPOSITORY [REFERENCE] [PATH]",
241 		Aliases:           []string{"cat", "show"},
242-		Short:             "Print out the contents of file at path.",
243+		Short:             "Print out the contents of file at path",
244 		Args:              cobra.RangeArgs(1, 3),
245 		PersistentPreRunE: checkIfReadable,
246 		RunE: func(cmd *cobra.Command, args []string) error {
247diff --git a/server/cmd/cmd.go b/server/cmd/cmd.go
248index a23ee8afdb5c752b710b576b579aeec1fdbb5b18..461a9041f126e3526dfb0fed2f5f7bce7cc0d914 100644
249--- a/server/cmd/cmd.go
250+++ b/server/cmd/cmd.go
251@@ -4,8 +4,10 @@ import (
252 	"context"
253 	"fmt"
254 
255+	"github.com/charmbracelet/log"
256 	"github.com/charmbracelet/soft-serve/server/backend"
257 	"github.com/charmbracelet/soft-serve/server/config"
258+	"github.com/charmbracelet/soft-serve/server/hooks"
259 	"github.com/charmbracelet/soft-serve/server/utils"
260 	"github.com/charmbracelet/ssh"
261 	"github.com/charmbracelet/wish"
262@@ -25,6 +27,8 @@ var (
263 	ConfigCtxKey = ContextKey("config")
264 	// SessionCtxKey is the key for the session in the context.
265 	SessionCtxKey = ContextKey("session")
266+	// HooksCtxKey is the key for the git hooks in the context.
267+	HooksCtxKey = ContextKey("hooks")
268 )
269 
270 var (
271@@ -36,6 +40,10 @@ var (
272 	ErrFileNotFound = fmt.Errorf("File not found")
273 )
274 
275+var (
276+	logger = log.WithPrefix("server.cmd")
277+)
278+
279 // rootCommand is the root command for the server.
280 func rootCommand() *cobra.Command {
281 	rootCmd := &cobra.Command{
282@@ -47,15 +55,17 @@ func rootCommand() *cobra.Command {
283 	rootCmd.CompletionOptions.DisableDefaultCmd = true
284 	rootCmd.AddCommand(
285 		adminCommand(),
286+		blobCommand(),
287 		branchCommand(),
288 		collabCommand(),
289 		createCommand(),
290 		deleteCommand(),
291 		descriptionCommand(),
292+		hookCommand(),
293 		listCommand(),
294 		privateCommand(),
295 		renameCommand(),
296-		blobCommand(),
297+		settingCommand(),
298 		tagCommand(),
299 		treeCommand(),
300 	)
301@@ -107,7 +117,7 @@ func checkIfCollab(cmd *cobra.Command, args []string) error {
302 }
303 
304 // Middleware is the Soft Serve middleware that handles SSH commands.
305-func Middleware(cfg *config.Config) wish.Middleware {
306+func Middleware(cfg *config.Config, hooks hooks.Hooks) wish.Middleware {
307 	return func(sh ssh.Handler) ssh.Handler {
308 		return func(s ssh.Session) {
309 			func() {
310@@ -128,6 +138,7 @@ func Middleware(cfg *config.Config) wish.Middleware {
311 
312 				ctx := context.WithValue(s.Context(), ConfigCtxKey, cfg)
313 				ctx = context.WithValue(ctx, SessionCtxKey, s)
314+				ctx = context.WithValue(ctx, HooksCtxKey, hooks)
315 
316 				rootCmd := rootCommand()
317 				rootCmd.SetArgs(args)
318diff --git a/server/cmd/create.go b/server/cmd/create.go
319index 89c261d12bfa8ef3cb6b8bc652652a3158391586..d7cfdb04da9c1e5371bea6207c14308b40cacea2 100644
320--- a/server/cmd/create.go
321+++ b/server/cmd/create.go
322@@ -8,7 +8,7 @@ func createCommand() *cobra.Command {
323 	var description string
324 	cmd := &cobra.Command{
325 		Use:               "create REPOSITORY",
326-		Short:             "Create a new repository.",
327+		Short:             "Create a new repository",
328 		Args:              cobra.ExactArgs(1),
329 		PersistentPreRunE: checkIfAdmin,
330 		RunE: func(cmd *cobra.Command, args []string) error {
331diff --git a/server/cmd/delete.go b/server/cmd/delete.go
332index 8dd94a64552f34620d801fe04a303aa88e24505b..7c335ac6c403e6ec46eedbb9aa6d8177692a6be3 100644
333--- a/server/cmd/delete.go
334+++ b/server/cmd/delete.go
335@@ -6,7 +6,7 @@ func deleteCommand() *cobra.Command {
336 	cmd := &cobra.Command{
337 		Use:               "delete REPOSITORY",
338 		Aliases:           []string{"del", "remove", "rm"},
339-		Short:             "Delete a repository.",
340+		Short:             "Delete a repository",
341 		Args:              cobra.ExactArgs(1),
342 		PersistentPreRunE: checkIfAdmin,
343 		RunE: func(cmd *cobra.Command, args []string) error {
344diff --git a/server/cmd/description.go b/server/cmd/description.go
345index 2eb59c5ebafa4628063374547390d352c7a704ea..bafabbfe8574904da2560442e541d80208550829 100644
346--- a/server/cmd/description.go
347+++ b/server/cmd/description.go
348@@ -10,7 +10,7 @@ func descriptionCommand() *cobra.Command {
349 	cmd := &cobra.Command{
350 		Use:     "description REPOSITORY [DESCRIPTION]",
351 		Aliases: []string{"desc"},
352-		Short:   "Set or get the description for a repository.",
353+		Short:   "Set or get the description for a repository",
354 		Args:    cobra.MinimumNArgs(1),
355 		RunE: func(cmd *cobra.Command, args []string) error {
356 			cfg, _ := fromContext(cmd)
357diff --git a/server/cmd/hook.go b/server/cmd/hook.go
358new file mode 100644
359index 0000000000000000000000000000000000000000..afd4ae2f19d5c2ae8f5387a631e51ac4608edb92
360--- /dev/null
361+++ b/server/cmd/hook.go
362@@ -0,0 +1,145 @@
363+package cmd
364+
365+import (
366+	"bufio"
367+	"fmt"
368+	"path/filepath"
369+	"strings"
370+
371+	"github.com/charmbracelet/keygen"
372+	"github.com/charmbracelet/soft-serve/server/hooks"
373+	"github.com/charmbracelet/ssh"
374+	"github.com/spf13/cobra"
375+	gossh "golang.org/x/crypto/ssh"
376+)
377+
378+// hookCommand handles Soft Serve internal API git hook requests.
379+func hookCommand() *cobra.Command {
380+	preReceiveCmd := &cobra.Command{
381+		Use:               "pre-receive",
382+		Short:             "Run git pre-receive hook",
383+		PersistentPreRunE: checkIfInternal,
384+		RunE: func(cmd *cobra.Command, args []string) error {
385+			_, s := fromContext(cmd)
386+			hks := cmd.Context().Value(HooksCtxKey).(hooks.Hooks)
387+			repoName := getRepoName(s)
388+			opts := make([]hooks.HookArg, 0)
389+			scanner := bufio.NewScanner(s)
390+			for scanner.Scan() {
391+				fields := strings.Fields(scanner.Text())
392+				if len(fields) != 3 {
393+					return fmt.Errorf("invalid pre-receive hook input: %s", scanner.Text())
394+				}
395+				opts = append(opts, hooks.HookArg{
396+					OldSha:  fields[0],
397+					NewSha:  fields[1],
398+					RefName: fields[2],
399+				})
400+			}
401+			hks.PreReceive(s, s.Stderr(), repoName, opts)
402+			return nil
403+		},
404+	}
405+
406+	updateCmd := &cobra.Command{
407+		Use:               "update",
408+		Short:             "Run git update hook",
409+		Args:              cobra.ExactArgs(3),
410+		PersistentPreRunE: checkIfInternal,
411+		RunE: func(cmd *cobra.Command, args []string) error {
412+			_, s := fromContext(cmd)
413+			hks := cmd.Context().Value(HooksCtxKey).(hooks.Hooks)
414+			repoName := getRepoName(s)
415+			hks.Update(s, s.Stderr(), repoName, hooks.HookArg{
416+				RefName: args[0],
417+				OldSha:  args[1],
418+				NewSha:  args[2],
419+			})
420+			return nil
421+		},
422+	}
423+
424+	postReceiveCmd := &cobra.Command{
425+		Use:               "post-receive",
426+		Short:             "Run git post-receive hook",
427+		PersistentPreRunE: checkIfInternal,
428+		RunE: func(cmd *cobra.Command, _ []string) error {
429+			_, s := fromContext(cmd)
430+			hks := cmd.Context().Value(HooksCtxKey).(hooks.Hooks)
431+			repoName := getRepoName(s)
432+			opts := make([]hooks.HookArg, 0)
433+			scanner := bufio.NewScanner(s)
434+			for scanner.Scan() {
435+				fields := strings.Fields(scanner.Text())
436+				if len(fields) != 3 {
437+					return fmt.Errorf("invalid post-receive hook input: %s", scanner.Text())
438+				}
439+				opts = append(opts, hooks.HookArg{
440+					OldSha:  fields[0],
441+					NewSha:  fields[1],
442+					RefName: fields[2],
443+				})
444+			}
445+			hks.PostReceive(s, s.Stderr(), repoName, opts)
446+			return nil
447+		},
448+	}
449+
450+	postUpdateCmd := &cobra.Command{
451+		Use:               "post-update",
452+		Short:             "Run git post-update hook",
453+		PersistentPreRunE: checkIfInternal,
454+		RunE: func(cmd *cobra.Command, args []string) error {
455+			_, s := fromContext(cmd)
456+			hks := cmd.Context().Value(HooksCtxKey).(hooks.Hooks)
457+			repoName := getRepoName(s)
458+			hks.PostUpdate(s, s.Stderr(), repoName, args...)
459+			return nil
460+		},
461+	}
462diff --git a/server/cmd/list.go b/server/cmd/list.go
463index 8ed558a15cdfc55d8c9cd19889b03a258c8988d4..7b6626c56ea0b7d1a472f944318620089d50610f 100644
464--- a/server/cmd/list.go
465+++ b/server/cmd/list.go
466@@ -10,7 +10,7 @@ func listCommand() *cobra.Command {
467 	listCmd := &cobra.Command{
468 		Use:     "list",
469 		Aliases: []string{"ls"},
470-		Short:   "List repositories.",
471+		Short:   "List repositories",
472 		Args:    cobra.NoArgs,
473 		RunE: func(cmd *cobra.Command, args []string) error {
474 			cfg, s := fromContext(cmd)
475diff --git a/server/cmd/private.go b/server/cmd/private.go
476index 593845dfb81b7787047eef73b6a1053021c2750b..83e8e77ca72cfc022ad74c49d58b1268fc930e63 100644
477--- a/server/cmd/private.go
478+++ b/server/cmd/private.go
479@@ -10,7 +10,7 @@ import (
480 func privateCommand() *cobra.Command {
481 	cmd := &cobra.Command{
482 		Use:   "private REPOSITORY [true|false]",
483-		Short: "Set or get a repository private property.",
484+		Short: "Set or get a repository private property",
485 		Args:  cobra.RangeArgs(1, 2),
486 		RunE: func(cmd *cobra.Command, args []string) error {
487 			cfg, _ := fromContext(cmd)
488diff --git a/server/cmd/rename.go b/server/cmd/rename.go
489index a5b88d83ac7f654ab3cab13fb90571ca1fd39cbe..d3ab7b0ac8c52379f886b79a3826146550beba82 100644
490--- a/server/cmd/rename.go
491+++ b/server/cmd/rename.go
492@@ -5,7 +5,8 @@ import "github.com/spf13/cobra"
493 func renameCommand() *cobra.Command {
494 	cmd := &cobra.Command{
495 		Use:               "rename REPOSITORY NEW_NAME",
496-		Short:             "Rename an existing repository.",
497+		Aliases:           []string{"mv", "move"},
498+		Short:             "Rename an existing repository",
499 		Args:              cobra.ExactArgs(2),
500 		PersistentPreRunE: checkIfCollab,
501 		RunE: func(cmd *cobra.Command, args []string) error {
502diff --git a/server/cmd/setting.go b/server/cmd/setting.go
503index 8faea89c5c2d7a001b5c59d28791f226c66bcab6..eaa55942ef0884abef3cd0b24a96f02b53cd1a7c 100644
504--- a/server/cmd/setting.go
505+++ b/server/cmd/setting.go
506@@ -11,7 +11,7 @@ import (
507 func settingCommand() *cobra.Command {
508 	cmd := &cobra.Command{
509 		Use:   "setting",
510-		Short: "Manage settings",
511+		Short: "Manage server settings",
512 	}
513 
514 	cmd.AddCommand(
515@@ -37,12 +37,13 @@ func settingCommand() *cobra.Command {
516 		},
517 	)
518 
519+	als := []string{backend.NoAccess.String(), backend.ReadOnlyAccess.String(), backend.ReadWriteAccess.String(), backend.AdminAccess.String()}
520 	cmd.AddCommand(
521 		&cobra.Command{
522 			Use:               "anon-access [ACCESS_LEVEL]",
523 			Short:             "Set or get the default access level for anonymous users",
524 			Args:              cobra.RangeArgs(0, 1),
525-			ValidArgs:         []string{backend.NoAccess.String(), backend.ReadOnlyAccess.String(), backend.ReadWriteAccess.String(), backend.AdminAccess.String()},
526+			ValidArgs:         als,
527 			PersistentPreRunE: checkIfAdmin,
528 			RunE: func(cmd *cobra.Command, args []string) error {
529 				cfg, _ := fromContext(cmd)
530@@ -52,7 +53,7 @@ func settingCommand() *cobra.Command {
531 				case 1:
532 					al := backend.ParseAccessLevel(args[0])
533 					if al < 0 {
534-						return fmt.Errorf("invalid access level: %s", args[0])
535+						return fmt.Errorf("invalid access level: %s. Please choose one of the following: %s", args[0], als)
536 					}
537 					if err := cfg.Backend.SetAnonAccess(al); err != nil {
538 						return err
539diff --git a/server/cmd/tree.go b/server/cmd/tree.go
540index 43be304c6a1eb640ed7748d1408ad6b1fdb5b128..0ca08a922d90d3a2dd88631454d15411d841a80a 100644
541--- a/server/cmd/tree.go
542+++ b/server/cmd/tree.go
543@@ -12,7 +12,7 @@ import (
544 func treeCommand() *cobra.Command {
545 	cmd := &cobra.Command{
546 		Use:               "tree REPOSITORY [REFERENCE] [PATH]",
547-		Short:             "Print repository tree at path.",
548+		Short:             "Print repository tree at path",
549 		Args:              cobra.RangeArgs(1, 3),
550 		PersistentPreRunE: checkIfReadable,
551 		RunE: func(cmd *cobra.Command, args []string) error {
552diff --git a/server/daemon.go b/server/daemon.go
553index 86ad93e4167f908198a177fd176b9c79079db47d..6424988d75d361ab9b715526942face540ce00d5 100644
554--- a/server/daemon.go
555+++ b/server/daemon.go
556@@ -4,7 +4,6 @@ import (
557 	"bytes"
558 	"context"
559 	"fmt"
560-	"io"
561 	"net"
562 	"path/filepath"
563 	"sync"
564@@ -129,7 +128,7 @@ func (d *GitDaemon) Start() error {
565 }
566 
567 func fatal(c net.Conn, err error) {
568-	WritePktline(c, err)
569+	writePktline(c, err)
570 	if err := c.Close(); err != nil {
571 		logger.Debugf("git: error closing connection: %v", err)
572 	}
573@@ -184,13 +183,13 @@ func (d *GitDaemon) handleClient(conn net.Conn) {
574 			return
575 		}
576 
577-		var gitPack func(io.Reader, io.Writer, io.Writer, string) error
578+		gitPack := uploadPack
579 		cmd := string(split[0])
580 		switch cmd {
581-		case UploadPackBin:
582-			gitPack = UploadPack
583-		case UploadArchiveBin:
584-			gitPack = UploadArchive
585+		case uploadPackBin:
586+			gitPack = uploadPack
587+		case uploadArchiveBin:
588+			gitPack = uploadArchive
589 		default:
590 			fatal(c, ErrInvalidRequest)
591 			return
592diff --git a/server/daemon_test.go b/server/daemon_test.go
593index 220624000d8cd733a1686f0bcb38214376059db3..d7816537297e1c516dc3e95e3ab2bbbc034a56ac 100644
594--- a/server/daemon_test.go
595+++ b/server/daemon_test.go
596@@ -35,7 +35,7 @@ func TestMain(m *testing.M) {
597 	if err != nil {
598 		log.Fatal(err)
599 	}
600-	cfg := config.DefaultConfig().WithBackend(fb).WithAccessMethod(fb)
601+	cfg := config.DefaultConfig().WithBackend(fb)
602 	d, err := NewGitDaemon(cfg)
603 	if err != nil {
604 		log.Fatal(err)
605diff --git a/server/git.go b/server/git.go
606index 1748f1ec4d7a1fcc6579a1b0ab7a1b6f4b726d2e..41a1609f1710051b7b1f46ebe0a789386c59a54d 100644
607--- a/server/git.go
608+++ b/server/git.go
609@@ -36,13 +36,13 @@ var (
610 
611 // Git protocol commands.
612 const (
613-	ReceivePackBin   = "git-receive-pack"
614-	UploadPackBin    = "git-upload-pack"
615-	UploadArchiveBin = "git-upload-archive"
616+	receivePackBin   = "git-receive-pack"
617+	uploadPackBin    = "git-upload-pack"
618+	uploadArchiveBin = "git-upload-archive"
619 )
620 
621-// UploadPack runs the git upload-pack protocol against the provided repo.
622-func UploadPack(in io.Reader, out io.Writer, er io.Writer, repoDir string) error {
623+// uploadPack runs the git upload-pack protocol against the provided repo.
624+func uploadPack(in io.Reader, out io.Writer, er io.Writer, repoDir string) error {
625 	exists, err := fileExists(repoDir)
626 	if !exists {
627 		return ErrInvalidRepo
628@@ -50,11 +50,11 @@ func UploadPack(in io.Reader, out io.Writer, er io.Writer, repoDir string) error
629 	if err != nil {
630 		return err
631 	}
632-	return RunGit(in, out, er, "", UploadPackBin[4:], repoDir)
633+	return runGit(in, out, er, "", uploadPackBin[4:], repoDir)
634 }
635 
636-// UploadArchive runs the git upload-archive protocol against the provided repo.
637-func UploadArchive(in io.Reader, out io.Writer, er io.Writer, repoDir string) error {
638+// uploadArchive runs the git upload-archive protocol against the provided repo.
639+func uploadArchive(in io.Reader, out io.Writer, er io.Writer, repoDir string) error {
640 	exists, err := fileExists(repoDir)
641 	if !exists {
642 		return ErrInvalidRepo
643@@ -62,22 +62,19 @@ func UploadArchive(in io.Reader, out io.Writer, er io.Writer, repoDir string) er
644 	if err != nil {
645 		return err
646 	}
647-	return RunGit(in, out, er, "", UploadArchiveBin[4:], repoDir)
648+	return runGit(in, out, er, "", uploadArchiveBin[4:], repoDir)
649 }
650 
651-// ReceivePack runs the git receive-pack protocol against the provided repo.
652-func ReceivePack(in io.Reader, out io.Writer, er io.Writer, repoDir string) error {
653-	if err := ensureRepo(repoDir, ""); err != nil {
654-		return err
655-	}
656-	if err := RunGit(in, out, er, "", ReceivePackBin[4:], repoDir); err != nil {
657+// receivePack runs the git receive-pack protocol against the provided repo.
658+func receivePack(in io.Reader, out io.Writer, er io.Writer, repoDir string) error {
659+	if err := runGit(in, out, er, "", receivePackBin[4:], repoDir); err != nil {
660 		return err
661 	}
662 	return ensureDefaultBranch(in, out, er, repoDir)
663 }
664 
665-// RunGit runs a git command in the given repo.
666-func RunGit(in io.Reader, out io.Writer, err io.Writer, dir string, args ...string) error {
667+// runGit runs a git command in the given repo.
668+func runGit(in io.Reader, out io.Writer, err io.Writer, dir string, args ...string) error {
669 	c := git.NewCommand(args...)
670 	return c.RunInDirWithOptions(dir, git.RunInDirOptions{
671 		Stdin:  in,
672@@ -86,8 +83,8 @@ func RunGit(in io.Reader, out io.Writer, err io.Writer, dir string, args ...stri
673 	})
674 }
675 
676-// WritePktline encodes and writes a pktline to the given writer.
677-func WritePktline(w io.Writer, v ...interface{}) {
678+// writePktline encodes and writes a pktline to the given writer.
679+func writePktline(w io.Writer, v ...interface{}) {
680 	msg := fmt.Sprintln(v...)
681 	pkt := pktline.NewEncoder(w)
682 	if err := pkt.EncodeString(msg); err != nil {
683@@ -132,32 +129,6 @@ func fileExists(path string) (bool, error) {
684 	return true, err
685 }
686 
687-func ensureRepo(dir string, repo string) error {
688-	exists, err := fileExists(dir)
689-	if err != nil {
690-		return err
691-	}
692-	if !exists {
693-		err = os.MkdirAll(dir, os.ModeDir|os.FileMode(0700))
694-		if err != nil {
695-			return err
696-		}
697-	}
698-	rp := filepath.Join(dir, repo)
699-	exists, err = fileExists(rp)
700-	if err != nil {
701-		return err
702-	}
703-	// FIXME: use backend.CreateRepository
704-	if !exists {
705-		_, err := git.Init(rp, true)
706-		if err != nil {
707-			return err
708-		}
709diff --git a/server/hooks.go b/server/hooks.go
710new file mode 100644
711index 0000000000000000000000000000000000000000..a4f9ed5683f2c728cdf38854dd53f256ad780c22
712--- /dev/null
713+++ b/server/hooks.go
714@@ -0,0 +1,52 @@
715+package server
716+
717+import (
718+	"io"
719+
720+	"github.com/charmbracelet/soft-serve/server/hooks"
721+)
722+
723+var _ hooks.Hooks = (*Server)(nil)
724+
725+// PostReceive is called by the git post-receive hook.
726+//
727+// It implements Hooks.
728+func (*Server) PostReceive(stdout io.Writer, stderr io.Writer, repo string, args []hooks.HookArg) {
729+	logger.Debug("post-receive hook called", "repo", repo, "args", args)
730+}
731+
732+// PreReceive is called by the git pre-receive hook.
733+//
734+// It implements Hooks.
735+func (*Server) PreReceive(stdout io.Writer, stderr io.Writer, repo string, args []hooks.HookArg) {
736+	logger.Debug("pre-receive hook called", "repo", repo, "args", args)
737+}
738+
739+// Update is called by the git update hook.
740+//
741+// It implements Hooks.
742+func (*Server) Update(stdout io.Writer, stderr io.Writer, repo string, arg hooks.HookArg) {
743+	logger.Debug("update hook called", "repo", repo, "arg", arg)
744+}
745+
746+// PostUpdate is called by the git post-update hook.
747+//
748+// It implements Hooks.
749+func (s *Server) PostUpdate(stdout io.Writer, stderr io.Writer, repo string, args ...string) {
750+	rr, err := s.Config.Backend.Repository(repo)
751+	if err != nil {
752+		logger.WithPrefix("server.hooks.post-update").Error("error getting repository", "repo", repo, "err", err)
753+		return
754+	}
755+
756+	r, err := rr.Open()
757+	if err != nil {
758+		logger.WithPrefix("server.hooks.post-update").Error("error opening repository", "repo", repo, "err", err)
759+		return
760+	}
761+
762+	if err := r.UpdateServerInfo(); err != nil {
763+		logger.WithPrefix("server.hooks.post-update").Error("error updating server info", "repo", repo, "err", err)
764+		return
765+	}
766+}
767diff --git a/server/hooks/hooks.go b/server/hooks/hooks.go
768new file mode 100644
769index 0000000000000000000000000000000000000000..fb47b729b9a99a7df02edcf3cfe3fa955ca73ac7
770--- /dev/null
771+++ b/server/hooks/hooks.go
772@@ -0,0 +1,18 @@
773+package hooks
774+
775+import "io"
776+
777+// HookArg is an argument to a git hook.
778+type HookArg struct {
779+	OldSha  string
780+	NewSha  string
781+	RefName string
782+}
783+
784+// Hooks provides an interface for git server-side hooks.
785+type Hooks interface {
786+	PreReceive(stdout io.Writer, stderr io.Writer, repo string, args []HookArg)
787+	Update(stdout io.Writer, stderr io.Writer, repo string, arg HookArg)
788+	PostReceive(stdout io.Writer, stderr io.Writer, repo string, args []HookArg)
789+	PostUpdate(stdout io.Writer, stderr io.Writer, repo string, args ...string)
790+}
791diff --git a/server/server.go b/server/server.go
792index e3f66d3c5a6e49bc2ed4b9ecf6db364768647dd9..eb62692f1f77d465987f613a75cd9422e668d6d3 100644
793--- a/server/server.go
794+++ b/server/server.go
795@@ -3,7 +3,9 @@ package server
796 import (
797 	"context"
798 	"net/http"
799+	"path/filepath"
800 
801+	"github.com/charmbracelet/keygen"
802 	"github.com/charmbracelet/log"
803 
804 	"github.com/charmbracelet/soft-serve/server/backend"
805@@ -57,7 +59,7 @@ func NewServer(cfg *config.Config) (*Server, error) {
806 		Config:  cfg,
807 		Backend: cfg.Backend,
808 	}
809-	srv.SSHServer, err = NewSSHServer(cfg)
810+	srv.SSHServer, err = NewSSHServer(cfg, srv)
811 	if err != nil {
812 		return nil, err
813 	}
814diff --git a/server/server_test.go b/server/server_test.go
815index b3bdb9ebbafb89ac7987fb2744c01e3f4c41896b..5505bec565280f8ed92c0487eed52c31c26c9910 100644
816--- a/server/server_test.go
817+++ b/server/server_test.go
818@@ -8,7 +8,6 @@ import (
819 	"testing"
820 
821 	"github.com/charmbracelet/keygen"
822-	"github.com/charmbracelet/soft-serve/server/backend/noop"
823 	"github.com/charmbracelet/soft-serve/server/config"
824 	"github.com/charmbracelet/ssh"
825 	"github.com/matryer/is"
826@@ -32,9 +31,7 @@ func setupServer(tb testing.TB) (*Server, *config.Config, string) {
827 	tb.Setenv("SOFT_SERVE_SSH_LISTEN_ADDR", sshPort)
828 	tb.Setenv("SOFT_SERVE_GIT_LISTEN_ADDR", fmt.Sprintf(":%d", randomPort()))
829 	cfg := config.DefaultConfig()
830-	nop := &noop.Noop{Port: sshPort[1:]}
831 	tb.Log("configuring server")
832-	cfg = cfg.WithBackend(nop).WithAccessMethod(nop)
833 	s, err := NewServer(cfg)
834 	if err != nil {
835 		tb.Fatal(err)
836diff --git a/server/session_test.go b/server/session_test.go
837index a120321a266005fdf6d98f6504dde1523e5ffc4b..542d312c6a5c7778dda1a45c4cea12fe42871091 100644
838--- a/server/session_test.go
839+++ b/server/session_test.go
840@@ -56,7 +56,7 @@ func setup(tb testing.TB) *gossh.Session {
841 	if err != nil {
842 		log.Fatal(err)
843 	}
844-	cfg := config.DefaultConfig().WithBackend(fb).WithAccessMethod(fb)
845+	cfg := config.DefaultConfig().WithBackend(fb)
846 	return testsession.New(tb, &ssh.Server{
847 		Handler: bm.MiddlewareWithProgramHandler(SessionHandler(cfg), termenv.ANSI256)(func(s ssh.Session) {
848 			_, _, active := s.Pty()
849diff --git a/server/ssh.go b/server/ssh.go
850index 61e8f7f54c7072866f7927b583375b6cfb11802b..6a73e935ac43b0a25872cfb9831e61c311e98112 100644
851--- a/server/ssh.go
852+++ b/server/ssh.go
853@@ -12,6 +12,7 @@ import (
854 	"github.com/charmbracelet/soft-serve/server/backend"
855 	cm "github.com/charmbracelet/soft-serve/server/cmd"
856 	"github.com/charmbracelet/soft-serve/server/config"
857+	"github.com/charmbracelet/soft-serve/server/hooks"
858 	"github.com/charmbracelet/soft-serve/server/utils"
859 	"github.com/charmbracelet/ssh"
860 	"github.com/charmbracelet/wish"
861@@ -29,7 +30,7 @@ type SSHServer struct {
862 }
863 
864 // NewSSHServer returns a new SSHServer.
865-func NewSSHServer(cfg *config.Config) (*SSHServer, error) {
866+func NewSSHServer(cfg *config.Config, hooks hooks.Hooks) (*SSHServer, error) {
867 	var err error
868 	s := &SSHServer{cfg: cfg}
869 	logger := logger.StandardLog(log.StandardLogOptions{ForceLevel: log.DebugLevel})
870@@ -39,7 +40,7 @@ func NewSSHServer(cfg *config.Config) (*SSHServer, error) {
871 			// BubbleTea middleware.
872 			bm.MiddlewareWithProgramHandler(SessionHandler(cfg), termenv.ANSI256),
873 			// CLI middleware.
874-			cm.Middleware(cfg),
875+			cm.Middleware(cfg, hooks),
876 			// Git middleware.
877 			s.Middleware(cfg),
878 			// Logging middleware.
879@@ -50,7 +51,7 @@ func NewSSHServer(cfg *config.Config) (*SSHServer, error) {
880 		ssh.PublicKeyAuth(s.PublicKeyHandler),
881 		ssh.KeyboardInteractiveAuth(s.KeyboardInteractiveHandler),
882 		wish.WithAddress(cfg.SSH.ListenAddr),
883-		wish.WithHostKeyPath(cfg.SSH.KeyPath),
884+		wish.WithHostKeyPath(filepath.Join(cfg.DataPath, cfg.SSH.KeyPath)),
885 		wish.WithMiddleware(mw...),
886 	)
887 	if err != nil {
888@@ -89,7 +90,7 @@ func (s *SSHServer) Shutdown(ctx context.Context) error {
889 
890 // PublicKeyAuthHandler handles public key authentication.
891 func (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) bool {
892-	return s.cfg.Backend.AccessLevel("", pk) > backend.NoAccess
893+	return s.cfg.Backend.AccessLevel("", pk) >= backend.ReadOnlyAccess
894 }
895 
896 // KeyboardInteractiveHandler handles keyboard interactive authentication.
897@@ -116,7 +117,6 @@ func (s *SSHServer) Middleware(cfg *config.Config) wish.Middleware {
898 					// git bare repositories should end in ".git"
899 					// https://git-scm.com/docs/gitrepository-layout
900 					repo := name + ".git"
901-
902 					reposDir := filepath.Join(cfg.DataPath, "repos")
903 					if err := ensureWithin(reposDir, repo); err != nil {
904 						sshFatal(s, err)
905@@ -125,30 +125,30 @@ func (s *SSHServer) Middleware(cfg *config.Config) wish.Middleware {
906 
907 					repoDir := filepath.Join(reposDir, repo)
908 					switch gc {
909-					case ReceivePackBin:
910+					case receivePackBin:
911 						if access < backend.ReadWriteAccess {
912 							sshFatal(s, ErrNotAuthed)
913 							return
914 						}
915 						if _, err := cfg.Backend.Repository(name); err != nil {
916 							if _, err := cfg.Backend.CreateRepository(name, false); err != nil {
917-								log.Printf("failed to create repo: %s", err)
918+								log.Errorf("failed to create repo: %s", err)
919 								sshFatal(s, err)
920 								return
921 							}
922 						}
923-						if err := ReceivePack(s, s, s.Stderr(), repoDir); err != nil {
924+						if err := receivePack(s, s, s.Stderr(), repoDir); err != nil {
925 							sshFatal(s, ErrSystemMalfunction)
926 						}
927 						return
928-					case UploadPackBin, UploadArchiveBin:
929+					case uploadPackBin, uploadArchiveBin:
930 						if access < backend.ReadOnlyAccess {
931 							sshFatal(s, ErrNotAuthed)
932 							return
933 						}
934-						gitPack := UploadPack
935-						if gc == UploadArchiveBin {
936-							gitPack = UploadArchive
937+						gitPack := uploadPack
938+						if gc == uploadArchiveBin {
939+							gitPack = uploadArchive
940 						}
941 						err := gitPack(s, s, s.Stderr(), repoDir)
942 						if errors.Is(err, ErrInvalidRepo) {
943@@ -166,6 +166,6 @@ func (s *SSHServer) Middleware(cfg *config.Config) wish.Middleware {
944 
945 // sshFatal prints to the session's STDOUT as a git response and exit 1.
946 func sshFatal(s ssh.Session, v ...interface{}) {
947-	WritePktline(s, v...)
948+	writePktline(s, v...)
949 	s.Exit(1) // nolint: errcheck
950 }