feat(server): add git hooks

Ayman Bagabas created

Change summary

cmd/soft/hook.go            | 215 +++++++++++++++++++++++++++++++++++++++
cmd/soft/root.go            |   1 
go.mod                      |   1 
server/backend/file/file.go | 179 +++++++++++++++++++++++++++++++
server/cmd/blob.go          |   2 
server/cmd/cmd.go           |  15 ++
server/cmd/create.go        |   2 
server/cmd/delete.go        |   2 
server/cmd/description.go   |   2 
server/cmd/hook.go          | 145 ++++++++++++++++++++++++++
server/cmd/list.go          |   2 
server/cmd/private.go       |   2 
server/cmd/rename.go        |   3 
server/cmd/setting.go       |   7 
server/cmd/tree.go          |   2 
server/daemon.go            |  13 +-
server/daemon_test.go       |   2 
server/git.go               |  64 +++--------
server/hooks.go             |  52 +++++++++
server/hooks/hooks.go       |  18 +++
server/server.go            |   4 
server/server_test.go       |   3 
server/session_test.go      |   2 
server/ssh.go               |  26 ++--
24 files changed, 674 insertions(+), 90 deletions(-)

Detailed changes

cmd/soft/hook.go 🔗

@@ -0,0 +1,215 @@
+package main
+
+import (
+	"bufio"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/charmbracelet/keygen"
+	"github.com/charmbracelet/soft-serve/server/config"
+	"github.com/spf13/cobra"
+	gossh "golang.org/x/crypto/ssh"
+)
+
+var (
+	configPath string
+
+	hookCmd = &cobra.Command{
+		Use:    "hook",
+		Short:  "Run git server hooks",
+		Long:   "Handles git server hooks. This includes pre-receive, update, and post-receive.",
+		Hidden: true,
+	}
+
+	preReceiveCmd = &cobra.Command{
+		Use:   "pre-receive",
+		Short: "Run git pre-receive hook",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			c, s, err := commonInit()
+			if err != nil {
+				return err
+			}
+			defer c.Close() //nolint:errcheck
+			defer s.Close() //nolint:errcheck
+			in, err := s.StdinPipe()
+			if err != nil {
+				return err
+			}
+			scanner := bufio.NewScanner(os.Stdin)
+			for scanner.Scan() {
+				in.Write([]byte(scanner.Text()))
+				in.Write([]byte("\n"))
+			}
+			in.Close() //nolint:errcheck
+			b, err := s.Output("hook pre-receive")
+			if err != nil {
+				return err
+			}
+			cmd.Print(string(b))
+			return nil
+		},
+	}
+
+	updateCmd = &cobra.Command{
+		Use:   "update",
+		Short: "Run git update hook",
+		Args:  cobra.ExactArgs(3),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			refName := args[0]
+			oldSha := args[1]
+			newSha := args[2]
+			c, s, err := commonInit()
+			if err != nil {
+				return err
+			}
+			defer c.Close() //nolint:errcheck
+			defer s.Close() //nolint:errcheck
+			b, err := s.Output(fmt.Sprintf("hook update %s %s %s", refName, oldSha, newSha))
+			if err != nil {
+				return err
+			}
+			cmd.Print(string(b))
+			return nil
+		},
+	}
+
+	postReceiveCmd = &cobra.Command{
+		Use:   "post-receive",
+		Short: "Run git post-receive hook",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			c, s, err := commonInit()
+			if err != nil {
+				return err
+			}
+			defer c.Close() //nolint:errcheck
+			defer s.Close() //nolint:errcheck
+			in, err := s.StdinPipe()
+			if err != nil {
+				return err
+			}
+			scanner := bufio.NewScanner(os.Stdin)
+			for scanner.Scan() {
+				in.Write([]byte(scanner.Text()))
+				in.Write([]byte("\n"))
+			}
+			in.Close() //nolint:errcheck
+			b, err := s.Output("hook post-receive")
+			if err != nil {
+				return err
+			}
+			cmd.Print(string(b))
+			return nil
+		},
+	}
+
+	postUpdateCmd = &cobra.Command{
+		Use:   "post-update",
+		Short: "Run git post-update hook",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			c, s, err := commonInit()
+			if err != nil {
+				return err
+			}
+			defer c.Close() //nolint:errcheck
+			defer s.Close() //nolint:errcheck
+			b, err := s.Output(fmt.Sprintf("hook post-update %s", strings.Join(args, " ")))
+			if err != nil {
+				return err
+			}
+			cmd.Print(string(b))
+			return nil
+		},
+	}
+)
+
+func init() {
+	hookCmd.AddCommand(
+		preReceiveCmd,
+		updateCmd,
+		postReceiveCmd,
+		postUpdateCmd,
+	)
+
+	hookCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "path to config file")
+}
+
+func commonInit() (c *gossh.Client, s *gossh.Session, err error) {
+	cfg, err := config.ParseConfig(configPath)
+	if err != nil {
+		return
+	}
+
+	// Use absolute path.
+	cfg.DataPath = filepath.Dir(configPath)
+
+	// Git runs the hook within the repository's directory.
+	// Get the working directory to determine the repository name.
+	wd, err := os.Getwd()
+	if err != nil {
+		return
+	}
+
+	rs, err := filepath.Abs(filepath.Join(cfg.DataPath, "repos"))
+	if err != nil {
+		return
+	}
+
+	if !strings.HasPrefix(wd, rs) {
+		err = fmt.Errorf("hook must be run from within repository directory")
+		return
+	}
+	repoName := strings.TrimPrefix(wd, rs)
+	repoName = strings.TrimPrefix(repoName, fmt.Sprintf("%c", os.PathSeparator))
+	c, err = newClient(cfg)
+	if err != nil {
+		return
+	}
+	s, err = newSession(c)
+	if err != nil {
+		return
+	}
+	s.Setenv("SOFT_SERVE_REPO_NAME", repoName)
+	return
+}
+
+func newClient(cfg *config.Config) (*gossh.Client, error) {
+	// Only accept the server's host key.
+	pk, err := keygen.New(filepath.Join(cfg.DataPath, cfg.SSH.KeyPath), nil, keygen.Ed25519)
+	if err != nil {
+		return nil, err
+	}
+	hostKey, err := gossh.ParsePrivateKey(pk.PrivateKeyPEM())
+	if err != nil {
+		return nil, err
+	}
+	ik, err := keygen.New(filepath.Join(cfg.DataPath, cfg.SSH.InternalKeyPath), nil, keygen.Ed25519)
+	if err != nil {
+		return nil, err
+	}
+	k, err := gossh.ParsePrivateKey(ik.PrivateKeyPEM())
+	if err != nil {
+		return nil, err
+	}
+	cc := &gossh.ClientConfig{
+		User: "internal",
+		Auth: []gossh.AuthMethod{
+			gossh.PublicKeys(k),
+		},
+		HostKeyCallback: gossh.FixedHostKey(hostKey.PublicKey()),
+	}
+	c, err := gossh.Dial("tcp", cfg.SSH.ListenAddr, cc)
+	if err != nil {
+		return nil, err
+	}
+	return c, nil
+}
+
+func newSession(c *gossh.Client) (*gossh.Session, error) {
+	s, err := c.NewSession()
+	if err != nil {
+		return nil, err
+	}
+	return s, nil
+}

cmd/soft/root.go 🔗

@@ -31,6 +31,7 @@ func init() {
 	rootCmd.AddCommand(
 		serveCmd,
 		manCmd,
+		hookCmd,
 	)
 	rootCmd.CompletionOptions.HiddenDefaultCmd = true
 

go.mod 🔗

@@ -32,6 +32,7 @@ require (
 	goji.io v2.0.2+incompatible
 	golang.org/x/crypto v0.7.0
 	golang.org/x/sync v0.1.0
+	gopkg.in/yaml.v3 v3.0.1
 )
 
 require (

server/backend/file/file.go 🔗

@@ -20,8 +20,10 @@ package file
 
 import (
 	"bufio"
+	"bytes"
 	"errors"
 	"fmt"
+	"html/template"
 	"io"
 	"io/fs"
 	"os"
@@ -584,18 +586,31 @@ func (fb *FileBackend) CreateRepository(repo string, private bool) (backend.Repo
 		return nil, os.ErrExist
 	}
 
-	if _, err := git.Init(rp, true); err != nil {
+	rr, err := git.Init(rp, true)
+	if err != nil {
 		logger.Debug("failed to create repository", "err", err)
 		return nil, err
 	}
 
-	fb.SetPrivate(repo, private)
-	fb.SetDescription(repo, "")
+	if err := rr.UpdateServerInfo(); err != nil {
+		logger.Debug("failed to update server info", "err", err)
+		return nil, err
+	}
+
+	if err := fb.SetPrivate(repo, private); err != nil {
+		logger.Debug("failed to set private status", "err", err)
+		return nil, err
+	}
+
+	if err := fb.SetDescription(repo, ""); err != nil {
+		logger.Debug("failed to set description", "err", err)
+		return nil, err
+	}
 
 	r := &Repo{path: rp, root: fb.reposPath()}
 	// Add to cache.
 	fb.repos[name] = r
-	return r, nil
+	return r, fb.InitializeHooks(name)
 }
 
 // DeleteRepository deletes the given repository.
@@ -687,6 +702,9 @@ func (fb *FileBackend) initRepos() error {
 			r := &Repo{path: path, root: fb.reposPath()}
 			fb.repos[r.Name()] = r
 			repos = append(repos, r)
+			if err := fb.InitializeHooks(r.Name()); err != nil {
+				logger.Warn("failed to initialize hooks", "err", err, "repo", r.Name())
+			}
 		}
 
 		return nil
@@ -709,3 +727,156 @@ func (fb *FileBackend) Repositories() ([]backend.Repository, error) {
 
 	return repos, nil
 }
+
+var (
+	hookNames = []string{"pre-receive", "update", "post-update", "post-receive"}
+	hookTpls  = []string{
+		// for pre-receive
+		`#!/usr/bin/env bash
+# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
+data=$(cat)
+exitcodes=""
+hookname=$(basename $0)
+GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
+for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
+  test -x "${hook}" && test -f "${hook}" || continue
+  echo "${data}" | "${hook}"
+  exitcodes="${exitcodes} $?"
+done
+for i in ${exitcodes}; do
+  [ ${i} -eq 0 ] || exit ${i}
+done
+`,
+
+		// for update
+		`#!/usr/bin/env bash
+# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
+exitcodes=""
+hookname=$(basename $0)
+GIT_DIR=${GIT_DIR:-$(dirname $0/..)}
+for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
+  test -x "${hook}" && test -f "${hook}" || continue
+  "${hook}" $1 $2 $3
+  exitcodes="${exitcodes} $?"
+done
+for i in ${exitcodes}; do
+  [ ${i} -eq 0 ] || exit ${i}
+done
+`,
+
+		// for post-update
+		`#!/usr/bin/env bash
+# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
+data=$(cat)
+exitcodes=""
+hookname=$(basename $0)
+GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
+for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
+  test -x "${hook}" && test -f "${hook}" || continue
+  "${hook}" $@
+  exitcodes="${exitcodes} $?"
+done
+for i in ${exitcodes}; do
+  [ ${i} -eq 0 ] || exit ${i}
+done
+`,
+
+		// for post-receive
+		`#!/usr/bin/env bash
+# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
+data=$(cat)
+exitcodes=""
+hookname=$(basename $0)
+GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
+for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
+  test -x "${hook}" && test -f "${hook}" || continue
+  echo "${data}" | "${hook}"
+  exitcodes="${exitcodes} $?"
+done
+for i in ${exitcodes}; do
+  [ ${i} -eq 0 ] || exit ${i}
+done
+`,
+	}
+)
+
+// InitializeHooks updates the hooks for the given repository.
+//
+// It implements backend.Backend.
+func (fb *FileBackend) InitializeHooks(repo string) error {
+	hookTmpl, err := template.New("hook").Parse(`#!/usr/bin/env bash
+# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
+{{ range $_, $env := .Envs }}
+{{ $env }} \{{ end }}
+{{ .Executable }} hook --config "{{ .Config }}" {{ .Hook }} {{ .Args }}
+`)
+	if err != nil {
+		return err
+	}
+
+	repo = utils.SanitizeRepo(repo) + ".git"
+	hooksPath := filepath.Join(fb.reposPath(), repo, "hooks")
+	if err := os.MkdirAll(hooksPath, 0755); err != nil {
+		return err
+	}
+
+	ex, err := os.Executable()
+	if err != nil {
+		return err
+	}
+
+	dp, err := filepath.Abs(fb.path)
+	if err != nil {
+		return fmt.Errorf("failed to get absolute path for data path: %w", err)
+	}
+
+	cp := filepath.Join(dp, "config.yaml")
+	envs := []string{}
+	for i, hook := range hookNames {
+		var data bytes.Buffer
+		var args string
+		hp := filepath.Join(hooksPath, hook)
+		if err := os.WriteFile(hp, []byte(hookTpls[i]), 0755); err != nil {
+			return err
+		}
+
+		// Create hook.d directory.
+		hp += ".d"
+		if err := os.MkdirAll(hp, 0755); err != nil {
+			return err
+		}
+
+		if hook == "update" {
+			args = "$1 $2 $3"
+		} else if hook == "post-update" {
+			args = "$@"
+		}
+
+		err = hookTmpl.Execute(&data, struct {
+			Executable string
+			Hook       string
+			Args       string
+			Envs       []string
+			Config     string
+		}{
+			Executable: ex,
+			Hook:       hook,
+			Args:       args,
+			Envs:       envs,
+			Config:     cp,
+		})
+		if err != nil {
+			logger.Error("failed to execute hook template", "err", err)
+			continue
+		}
+
+		hp = filepath.Join(hp, "soft-serve")
+		err = os.WriteFile(hp, data.Bytes(), 0755) //nolint:gosec
+		if err != nil {
+			logger.Error("failed to write hook", "err", err)
+			continue
+		}
+	}
+
+	return nil
+}

server/cmd/blob.go 🔗

@@ -30,7 +30,7 @@ func blobCommand() *cobra.Command {
 	cmd := &cobra.Command{
 		Use:               "blob REPOSITORY [REFERENCE] [PATH]",
 		Aliases:           []string{"cat", "show"},
-		Short:             "Print out the contents of file at path.",
+		Short:             "Print out the contents of file at path",
 		Args:              cobra.RangeArgs(1, 3),
 		PersistentPreRunE: checkIfReadable,
 		RunE: func(cmd *cobra.Command, args []string) error {

server/cmd/cmd.go 🔗

@@ -4,8 +4,10 @@ import (
 	"context"
 	"fmt"
 
+	"github.com/charmbracelet/log"
 	"github.com/charmbracelet/soft-serve/server/backend"
 	"github.com/charmbracelet/soft-serve/server/config"
+	"github.com/charmbracelet/soft-serve/server/hooks"
 	"github.com/charmbracelet/soft-serve/server/utils"
 	"github.com/charmbracelet/ssh"
 	"github.com/charmbracelet/wish"
@@ -25,6 +27,8 @@ var (
 	ConfigCtxKey = ContextKey("config")
 	// SessionCtxKey is the key for the session in the context.
 	SessionCtxKey = ContextKey("session")
+	// HooksCtxKey is the key for the git hooks in the context.
+	HooksCtxKey = ContextKey("hooks")
 )
 
 var (
@@ -36,6 +40,10 @@ var (
 	ErrFileNotFound = fmt.Errorf("File not found")
 )
 
+var (
+	logger = log.WithPrefix("server.cmd")
+)
+
 // rootCommand is the root command for the server.
 func rootCommand() *cobra.Command {
 	rootCmd := &cobra.Command{
@@ -47,15 +55,17 @@ func rootCommand() *cobra.Command {
 	rootCmd.CompletionOptions.DisableDefaultCmd = true
 	rootCmd.AddCommand(
 		adminCommand(),
+		blobCommand(),
 		branchCommand(),
 		collabCommand(),
 		createCommand(),
 		deleteCommand(),
 		descriptionCommand(),
+		hookCommand(),
 		listCommand(),
 		privateCommand(),
 		renameCommand(),
-		blobCommand(),
+		settingCommand(),
 		tagCommand(),
 		treeCommand(),
 	)
@@ -107,7 +117,7 @@ func checkIfCollab(cmd *cobra.Command, args []string) error {
 }
 
 // Middleware is the Soft Serve middleware that handles SSH commands.
-func Middleware(cfg *config.Config) wish.Middleware {
+func Middleware(cfg *config.Config, hooks hooks.Hooks) wish.Middleware {
 	return func(sh ssh.Handler) ssh.Handler {
 		return func(s ssh.Session) {
 			func() {
@@ -128,6 +138,7 @@ func Middleware(cfg *config.Config) wish.Middleware {
 
 				ctx := context.WithValue(s.Context(), ConfigCtxKey, cfg)
 				ctx = context.WithValue(ctx, SessionCtxKey, s)
+				ctx = context.WithValue(ctx, HooksCtxKey, hooks)
 
 				rootCmd := rootCommand()
 				rootCmd.SetArgs(args)

server/cmd/create.go 🔗

@@ -8,7 +8,7 @@ func createCommand() *cobra.Command {
 	var description string
 	cmd := &cobra.Command{
 		Use:               "create REPOSITORY",
-		Short:             "Create a new repository.",
+		Short:             "Create a new repository",
 		Args:              cobra.ExactArgs(1),
 		PersistentPreRunE: checkIfAdmin,
 		RunE: func(cmd *cobra.Command, args []string) error {

server/cmd/delete.go 🔗

@@ -6,7 +6,7 @@ func deleteCommand() *cobra.Command {
 	cmd := &cobra.Command{
 		Use:               "delete REPOSITORY",
 		Aliases:           []string{"del", "remove", "rm"},
-		Short:             "Delete a repository.",
+		Short:             "Delete a repository",
 		Args:              cobra.ExactArgs(1),
 		PersistentPreRunE: checkIfAdmin,
 		RunE: func(cmd *cobra.Command, args []string) error {

server/cmd/description.go 🔗

@@ -10,7 +10,7 @@ func descriptionCommand() *cobra.Command {
 	cmd := &cobra.Command{
 		Use:     "description REPOSITORY [DESCRIPTION]",
 		Aliases: []string{"desc"},
-		Short:   "Set or get the description for a repository.",
+		Short:   "Set or get the description for a repository",
 		Args:    cobra.MinimumNArgs(1),
 		RunE: func(cmd *cobra.Command, args []string) error {
 			cfg, _ := fromContext(cmd)

server/cmd/hook.go 🔗

@@ -0,0 +1,145 @@
+package cmd
+
+import (
+	"bufio"
+	"fmt"
+	"path/filepath"
+	"strings"
+
+	"github.com/charmbracelet/keygen"
+	"github.com/charmbracelet/soft-serve/server/hooks"
+	"github.com/charmbracelet/ssh"
+	"github.com/spf13/cobra"
+	gossh "golang.org/x/crypto/ssh"
+)
+
+// hookCommand handles Soft Serve internal API git hook requests.
+func hookCommand() *cobra.Command {
+	preReceiveCmd := &cobra.Command{
+		Use:               "pre-receive",
+		Short:             "Run git pre-receive hook",
+		PersistentPreRunE: checkIfInternal,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			_, s := fromContext(cmd)
+			hks := cmd.Context().Value(HooksCtxKey).(hooks.Hooks)
+			repoName := getRepoName(s)
+			opts := make([]hooks.HookArg, 0)
+			scanner := bufio.NewScanner(s)
+			for scanner.Scan() {
+				fields := strings.Fields(scanner.Text())
+				if len(fields) != 3 {
+					return fmt.Errorf("invalid pre-receive hook input: %s", scanner.Text())
+				}
+				opts = append(opts, hooks.HookArg{
+					OldSha:  fields[0],
+					NewSha:  fields[1],
+					RefName: fields[2],
+				})
+			}
+			hks.PreReceive(s, s.Stderr(), repoName, opts)
+			return nil
+		},
+	}
+
+	updateCmd := &cobra.Command{
+		Use:               "update",
+		Short:             "Run git update hook",
+		Args:              cobra.ExactArgs(3),
+		PersistentPreRunE: checkIfInternal,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			_, s := fromContext(cmd)
+			hks := cmd.Context().Value(HooksCtxKey).(hooks.Hooks)
+			repoName := getRepoName(s)
+			hks.Update(s, s.Stderr(), repoName, hooks.HookArg{
+				RefName: args[0],
+				OldSha:  args[1],
+				NewSha:  args[2],
+			})
+			return nil
+		},
+	}
+
+	postReceiveCmd := &cobra.Command{
+		Use:               "post-receive",
+		Short:             "Run git post-receive hook",
+		PersistentPreRunE: checkIfInternal,
+		RunE: func(cmd *cobra.Command, _ []string) error {
+			_, s := fromContext(cmd)
+			hks := cmd.Context().Value(HooksCtxKey).(hooks.Hooks)
+			repoName := getRepoName(s)
+			opts := make([]hooks.HookArg, 0)
+			scanner := bufio.NewScanner(s)
+			for scanner.Scan() {
+				fields := strings.Fields(scanner.Text())
+				if len(fields) != 3 {
+					return fmt.Errorf("invalid post-receive hook input: %s", scanner.Text())
+				}
+				opts = append(opts, hooks.HookArg{
+					OldSha:  fields[0],
+					NewSha:  fields[1],
+					RefName: fields[2],
+				})
+			}
+			hks.PostReceive(s, s.Stderr(), repoName, opts)
+			return nil
+		},
+	}
+
+	postUpdateCmd := &cobra.Command{
+		Use:               "post-update",
+		Short:             "Run git post-update hook",
+		PersistentPreRunE: checkIfInternal,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			_, s := fromContext(cmd)
+			hks := cmd.Context().Value(HooksCtxKey).(hooks.Hooks)
+			repoName := getRepoName(s)
+			hks.PostUpdate(s, s.Stderr(), repoName, args...)
+			return nil
+		},
+	}
+
+	hookCmd := &cobra.Command{
+		Use:          "hook",
+		Short:        "Run git server hooks",
+		Hidden:       true,
+		SilenceUsage: true,
+	}
+
+	hookCmd.AddCommand(
+		preReceiveCmd,
+		updateCmd,
+		postReceiveCmd,
+		postUpdateCmd,
+	)
+
+	return hookCmd
+}
+
+// Check if the session's public key matches the internal API key.
+func checkIfInternal(cmd *cobra.Command, _ []string) error {
+	cfg, s := fromContext(cmd)
+	pk := s.PublicKey()
+	kp, err := keygen.New(filepath.Join(cfg.DataPath, cfg.SSH.InternalKeyPath), nil, keygen.Ed25519)
+	if err != nil {
+		logger.Errorf("failed to read internal key: %v", err)
+		return err
+	}
+	priv, err := gossh.ParsePrivateKey(kp.PrivateKeyPEM())
+	if err != nil {
+		return err
+	}
+	if !ssh.KeysEqual(pk, priv.PublicKey()) {
+		return ErrUnauthorized
+	}
+	return nil
+}
+
+func getRepoName(s ssh.Session) string {
+	var repoName string
+	for _, env := range s.Environ() {
+		if strings.HasPrefix(env, "SOFT_SERVE_REPO_NAME=") {
+			return strings.TrimPrefix(env, "SOFT_SERVE_REPO_NAME=")
+		}
+	}
+	return repoName
+}

server/cmd/list.go 🔗

@@ -10,7 +10,7 @@ func listCommand() *cobra.Command {
 	listCmd := &cobra.Command{
 		Use:     "list",
 		Aliases: []string{"ls"},
-		Short:   "List repositories.",
+		Short:   "List repositories",
 		Args:    cobra.NoArgs,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			cfg, s := fromContext(cmd)

server/cmd/private.go 🔗

@@ -10,7 +10,7 @@ import (
 func privateCommand() *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "private REPOSITORY [true|false]",
-		Short: "Set or get a repository private property.",
+		Short: "Set or get a repository private property",
 		Args:  cobra.RangeArgs(1, 2),
 		RunE: func(cmd *cobra.Command, args []string) error {
 			cfg, _ := fromContext(cmd)

server/cmd/rename.go 🔗

@@ -5,7 +5,8 @@ import "github.com/spf13/cobra"
 func renameCommand() *cobra.Command {
 	cmd := &cobra.Command{
 		Use:               "rename REPOSITORY NEW_NAME",
-		Short:             "Rename an existing repository.",
+		Aliases:           []string{"mv", "move"},
+		Short:             "Rename an existing repository",
 		Args:              cobra.ExactArgs(2),
 		PersistentPreRunE: checkIfCollab,
 		RunE: func(cmd *cobra.Command, args []string) error {

server/cmd/setting.go 🔗

@@ -11,7 +11,7 @@ import (
 func settingCommand() *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "setting",
-		Short: "Manage settings",
+		Short: "Manage server settings",
 	}
 
 	cmd.AddCommand(
@@ -37,12 +37,13 @@ func settingCommand() *cobra.Command {
 		},
 	)
 
+	als := []string{backend.NoAccess.String(), backend.ReadOnlyAccess.String(), backend.ReadWriteAccess.String(), backend.AdminAccess.String()}
 	cmd.AddCommand(
 		&cobra.Command{
 			Use:               "anon-access [ACCESS_LEVEL]",
 			Short:             "Set or get the default access level for anonymous users",
 			Args:              cobra.RangeArgs(0, 1),
-			ValidArgs:         []string{backend.NoAccess.String(), backend.ReadOnlyAccess.String(), backend.ReadWriteAccess.String(), backend.AdminAccess.String()},
+			ValidArgs:         als,
 			PersistentPreRunE: checkIfAdmin,
 			RunE: func(cmd *cobra.Command, args []string) error {
 				cfg, _ := fromContext(cmd)
@@ -52,7 +53,7 @@ func settingCommand() *cobra.Command {
 				case 1:
 					al := backend.ParseAccessLevel(args[0])
 					if al < 0 {
-						return fmt.Errorf("invalid access level: %s", args[0])
+						return fmt.Errorf("invalid access level: %s. Please choose one of the following: %s", args[0], als)
 					}
 					if err := cfg.Backend.SetAnonAccess(al); err != nil {
 						return err

server/cmd/tree.go 🔗

@@ -12,7 +12,7 @@ import (
 func treeCommand() *cobra.Command {
 	cmd := &cobra.Command{
 		Use:               "tree REPOSITORY [REFERENCE] [PATH]",
-		Short:             "Print repository tree at path.",
+		Short:             "Print repository tree at path",
 		Args:              cobra.RangeArgs(1, 3),
 		PersistentPreRunE: checkIfReadable,
 		RunE: func(cmd *cobra.Command, args []string) error {

server/daemon.go 🔗

@@ -4,7 +4,6 @@ import (
 	"bytes"
 	"context"
 	"fmt"
-	"io"
 	"net"
 	"path/filepath"
 	"sync"
@@ -129,7 +128,7 @@ func (d *GitDaemon) Start() error {
 }
 
 func fatal(c net.Conn, err error) {
-	WritePktline(c, err)
+	writePktline(c, err)
 	if err := c.Close(); err != nil {
 		logger.Debugf("git: error closing connection: %v", err)
 	}
@@ -184,13 +183,13 @@ func (d *GitDaemon) handleClient(conn net.Conn) {
 			return
 		}
 
-		var gitPack func(io.Reader, io.Writer, io.Writer, string) error
+		gitPack := uploadPack
 		cmd := string(split[0])
 		switch cmd {
-		case UploadPackBin:
-			gitPack = UploadPack
-		case UploadArchiveBin:
-			gitPack = UploadArchive
+		case uploadPackBin:
+			gitPack = uploadPack
+		case uploadArchiveBin:
+			gitPack = uploadArchive
 		default:
 			fatal(c, ErrInvalidRequest)
 			return

server/daemon_test.go 🔗

@@ -35,7 +35,7 @@ func TestMain(m *testing.M) {
 	if err != nil {
 		log.Fatal(err)
 	}
-	cfg := config.DefaultConfig().WithBackend(fb).WithAccessMethod(fb)
+	cfg := config.DefaultConfig().WithBackend(fb)
 	d, err := NewGitDaemon(cfg)
 	if err != nil {
 		log.Fatal(err)

server/git.go 🔗

@@ -36,13 +36,13 @@ var (
 
 // Git protocol commands.
 const (
-	ReceivePackBin   = "git-receive-pack"
-	UploadPackBin    = "git-upload-pack"
-	UploadArchiveBin = "git-upload-archive"
+	receivePackBin   = "git-receive-pack"
+	uploadPackBin    = "git-upload-pack"
+	uploadArchiveBin = "git-upload-archive"
 )
 
-// UploadPack runs the git upload-pack protocol against the provided repo.
-func UploadPack(in io.Reader, out io.Writer, er io.Writer, repoDir string) error {
+// uploadPack runs the git upload-pack protocol against the provided repo.
+func uploadPack(in io.Reader, out io.Writer, er io.Writer, repoDir string) error {
 	exists, err := fileExists(repoDir)
 	if !exists {
 		return ErrInvalidRepo
@@ -50,11 +50,11 @@ func UploadPack(in io.Reader, out io.Writer, er io.Writer, repoDir string) error
 	if err != nil {
 		return err
 	}
-	return RunGit(in, out, er, "", UploadPackBin[4:], repoDir)
+	return runGit(in, out, er, "", uploadPackBin[4:], repoDir)
 }
 
-// UploadArchive runs the git upload-archive protocol against the provided repo.
-func UploadArchive(in io.Reader, out io.Writer, er io.Writer, repoDir string) error {
+// uploadArchive runs the git upload-archive protocol against the provided repo.
+func uploadArchive(in io.Reader, out io.Writer, er io.Writer, repoDir string) error {
 	exists, err := fileExists(repoDir)
 	if !exists {
 		return ErrInvalidRepo
@@ -62,22 +62,19 @@ func UploadArchive(in io.Reader, out io.Writer, er io.Writer, repoDir string) er
 	if err != nil {
 		return err
 	}
-	return RunGit(in, out, er, "", UploadArchiveBin[4:], repoDir)
+	return runGit(in, out, er, "", uploadArchiveBin[4:], repoDir)
 }
 
-// ReceivePack runs the git receive-pack protocol against the provided repo.
-func ReceivePack(in io.Reader, out io.Writer, er io.Writer, repoDir string) error {
-	if err := ensureRepo(repoDir, ""); err != nil {
-		return err
-	}
-	if err := RunGit(in, out, er, "", ReceivePackBin[4:], repoDir); err != nil {
+// receivePack runs the git receive-pack protocol against the provided repo.
+func receivePack(in io.Reader, out io.Writer, er io.Writer, repoDir string) error {
+	if err := runGit(in, out, er, "", receivePackBin[4:], repoDir); err != nil {
 		return err
 	}
 	return ensureDefaultBranch(in, out, er, repoDir)
 }
 
-// RunGit runs a git command in the given repo.
-func RunGit(in io.Reader, out io.Writer, err io.Writer, dir string, args ...string) error {
+// runGit runs a git command in the given repo.
+func runGit(in io.Reader, out io.Writer, err io.Writer, dir string, args ...string) error {
 	c := git.NewCommand(args...)
 	return c.RunInDirWithOptions(dir, git.RunInDirOptions{
 		Stdin:  in,
@@ -86,8 +83,8 @@ func RunGit(in io.Reader, out io.Writer, err io.Writer, dir string, args ...stri
 	})
 }
 
-// WritePktline encodes and writes a pktline to the given writer.
-func WritePktline(w io.Writer, v ...interface{}) {
+// writePktline encodes and writes a pktline to the given writer.
+func writePktline(w io.Writer, v ...interface{}) {
 	msg := fmt.Sprintln(v...)
 	pkt := pktline.NewEncoder(w)
 	if err := pkt.EncodeString(msg); err != nil {
@@ -132,32 +129,6 @@ func fileExists(path string) (bool, error) {
 	return true, err
 }
 
-func ensureRepo(dir string, repo string) error {
-	exists, err := fileExists(dir)
-	if err != nil {
-		return err
-	}
-	if !exists {
-		err = os.MkdirAll(dir, os.ModeDir|os.FileMode(0700))
-		if err != nil {
-			return err
-		}
-	}
-	rp := filepath.Join(dir, repo)
-	exists, err = fileExists(rp)
-	if err != nil {
-		return err
-	}
-	// FIXME: use backend.CreateRepository
-	if !exists {
-		_, err := git.Init(rp, true)
-		if err != nil {
-			return err
-		}
-	}
-	return nil
-}
-
 func ensureDefaultBranch(in io.Reader, out io.Writer, er io.Writer, repoPath string) error {
 	r, err := git.Open(repoPath)
 	if err != nil {
@@ -173,8 +144,7 @@ func ensureDefaultBranch(in io.Reader, out io.Writer, er io.Writer, repoPath str
 	// Rename the default branch to the first branch available
 	_, err = r.HEAD()
 	if err == git.ErrReferenceNotExist {
-		// FIXME: use backend.SetDefaultBranch
-		err = RunGit(in, out, er, repoPath, "branch", "-M", brs[0])
+		err = runGit(in, out, er, repoPath, "branch", "-M", brs[0])
 		if err != nil {
 			return err
 		}

server/hooks.go 🔗

@@ -0,0 +1,52 @@
+package server
+
+import (
+	"io"
+
+	"github.com/charmbracelet/soft-serve/server/hooks"
+)
+
+var _ hooks.Hooks = (*Server)(nil)
+
+// PostReceive is called by the git post-receive hook.
+//
+// It implements Hooks.
+func (*Server) PostReceive(stdout io.Writer, stderr io.Writer, repo string, args []hooks.HookArg) {
+	logger.Debug("post-receive hook called", "repo", repo, "args", args)
+}
+
+// PreReceive is called by the git pre-receive hook.
+//
+// It implements Hooks.
+func (*Server) PreReceive(stdout io.Writer, stderr io.Writer, repo string, args []hooks.HookArg) {
+	logger.Debug("pre-receive hook called", "repo", repo, "args", args)
+}
+
+// Update is called by the git update hook.
+//
+// It implements Hooks.
+func (*Server) Update(stdout io.Writer, stderr io.Writer, repo string, arg hooks.HookArg) {
+	logger.Debug("update hook called", "repo", repo, "arg", arg)
+}
+
+// PostUpdate is called by the git post-update hook.
+//
+// It implements Hooks.
+func (s *Server) PostUpdate(stdout io.Writer, stderr io.Writer, repo string, args ...string) {
+	rr, err := s.Config.Backend.Repository(repo)
+	if err != nil {
+		logger.WithPrefix("server.hooks.post-update").Error("error getting repository", "repo", repo, "err", err)
+		return
+	}
+
+	r, err := rr.Open()
+	if err != nil {
+		logger.WithPrefix("server.hooks.post-update").Error("error opening repository", "repo", repo, "err", err)
+		return
+	}
+
+	if err := r.UpdateServerInfo(); err != nil {
+		logger.WithPrefix("server.hooks.post-update").Error("error updating server info", "repo", repo, "err", err)
+		return
+	}
+}

server/hooks/hooks.go 🔗

@@ -0,0 +1,18 @@
+package hooks
+
+import "io"
+
+// HookArg is an argument to a git hook.
+type HookArg struct {
+	OldSha  string
+	NewSha  string
+	RefName string
+}
+
+// Hooks provides an interface for git server-side hooks.
+type Hooks interface {
+	PreReceive(stdout io.Writer, stderr io.Writer, repo string, args []HookArg)
+	Update(stdout io.Writer, stderr io.Writer, repo string, arg HookArg)
+	PostReceive(stdout io.Writer, stderr io.Writer, repo string, args []HookArg)
+	PostUpdate(stdout io.Writer, stderr io.Writer, repo string, args ...string)
+}

server/server.go 🔗

@@ -3,7 +3,9 @@ package server
 import (
 	"context"
 	"net/http"
+	"path/filepath"
 
+	"github.com/charmbracelet/keygen"
 	"github.com/charmbracelet/log"
 
 	"github.com/charmbracelet/soft-serve/server/backend"
@@ -57,7 +59,7 @@ func NewServer(cfg *config.Config) (*Server, error) {
 		Config:  cfg,
 		Backend: cfg.Backend,
 	}
-	srv.SSHServer, err = NewSSHServer(cfg)
+	srv.SSHServer, err = NewSSHServer(cfg, srv)
 	if err != nil {
 		return nil, err
 	}

server/server_test.go 🔗

@@ -8,7 +8,6 @@ import (
 	"testing"
 
 	"github.com/charmbracelet/keygen"
-	"github.com/charmbracelet/soft-serve/server/backend/noop"
 	"github.com/charmbracelet/soft-serve/server/config"
 	"github.com/charmbracelet/ssh"
 	"github.com/matryer/is"
@@ -32,9 +31,7 @@ func setupServer(tb testing.TB) (*Server, *config.Config, string) {
 	tb.Setenv("SOFT_SERVE_SSH_LISTEN_ADDR", sshPort)
 	tb.Setenv("SOFT_SERVE_GIT_LISTEN_ADDR", fmt.Sprintf(":%d", randomPort()))
 	cfg := config.DefaultConfig()
-	nop := &noop.Noop{Port: sshPort[1:]}
 	tb.Log("configuring server")
-	cfg = cfg.WithBackend(nop).WithAccessMethod(nop)
 	s, err := NewServer(cfg)
 	if err != nil {
 		tb.Fatal(err)

server/session_test.go 🔗

@@ -56,7 +56,7 @@ func setup(tb testing.TB) *gossh.Session {
 	if err != nil {
 		log.Fatal(err)
 	}
-	cfg := config.DefaultConfig().WithBackend(fb).WithAccessMethod(fb)
+	cfg := config.DefaultConfig().WithBackend(fb)
 	return testsession.New(tb, &ssh.Server{
 		Handler: bm.MiddlewareWithProgramHandler(SessionHandler(cfg), termenv.ANSI256)(func(s ssh.Session) {
 			_, _, active := s.Pty()

server/ssh.go 🔗

@@ -12,6 +12,7 @@ import (
 	"github.com/charmbracelet/soft-serve/server/backend"
 	cm "github.com/charmbracelet/soft-serve/server/cmd"
 	"github.com/charmbracelet/soft-serve/server/config"
+	"github.com/charmbracelet/soft-serve/server/hooks"
 	"github.com/charmbracelet/soft-serve/server/utils"
 	"github.com/charmbracelet/ssh"
 	"github.com/charmbracelet/wish"
@@ -29,7 +30,7 @@ type SSHServer struct {
 }
 
 // NewSSHServer returns a new SSHServer.
-func NewSSHServer(cfg *config.Config) (*SSHServer, error) {
+func NewSSHServer(cfg *config.Config, hooks hooks.Hooks) (*SSHServer, error) {
 	var err error
 	s := &SSHServer{cfg: cfg}
 	logger := logger.StandardLog(log.StandardLogOptions{ForceLevel: log.DebugLevel})
@@ -39,7 +40,7 @@ func NewSSHServer(cfg *config.Config) (*SSHServer, error) {
 			// BubbleTea middleware.
 			bm.MiddlewareWithProgramHandler(SessionHandler(cfg), termenv.ANSI256),
 			// CLI middleware.
-			cm.Middleware(cfg),
+			cm.Middleware(cfg, hooks),
 			// Git middleware.
 			s.Middleware(cfg),
 			// Logging middleware.
@@ -50,7 +51,7 @@ func NewSSHServer(cfg *config.Config) (*SSHServer, error) {
 		ssh.PublicKeyAuth(s.PublicKeyHandler),
 		ssh.KeyboardInteractiveAuth(s.KeyboardInteractiveHandler),
 		wish.WithAddress(cfg.SSH.ListenAddr),
-		wish.WithHostKeyPath(cfg.SSH.KeyPath),
+		wish.WithHostKeyPath(filepath.Join(cfg.DataPath, cfg.SSH.KeyPath)),
 		wish.WithMiddleware(mw...),
 	)
 	if err != nil {
@@ -89,7 +90,7 @@ func (s *SSHServer) Shutdown(ctx context.Context) error {
 
 // PublicKeyAuthHandler handles public key authentication.
 func (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) bool {
-	return s.cfg.Backend.AccessLevel("", pk) > backend.NoAccess
+	return s.cfg.Backend.AccessLevel("", pk) >= backend.ReadOnlyAccess
 }
 
 // KeyboardInteractiveHandler handles keyboard interactive authentication.
@@ -116,7 +117,6 @@ func (s *SSHServer) Middleware(cfg *config.Config) wish.Middleware {
 					// git bare repositories should end in ".git"
 					// https://git-scm.com/docs/gitrepository-layout
 					repo := name + ".git"
-
 					reposDir := filepath.Join(cfg.DataPath, "repos")
 					if err := ensureWithin(reposDir, repo); err != nil {
 						sshFatal(s, err)
@@ -125,30 +125,30 @@ func (s *SSHServer) Middleware(cfg *config.Config) wish.Middleware {
 
 					repoDir := filepath.Join(reposDir, repo)
 					switch gc {
-					case ReceivePackBin:
+					case receivePackBin:
 						if access < backend.ReadWriteAccess {
 							sshFatal(s, ErrNotAuthed)
 							return
 						}
 						if _, err := cfg.Backend.Repository(name); err != nil {
 							if _, err := cfg.Backend.CreateRepository(name, false); err != nil {
-								log.Printf("failed to create repo: %s", err)
+								log.Errorf("failed to create repo: %s", err)
 								sshFatal(s, err)
 								return
 							}
 						}
-						if err := ReceivePack(s, s, s.Stderr(), repoDir); err != nil {
+						if err := receivePack(s, s, s.Stderr(), repoDir); err != nil {
 							sshFatal(s, ErrSystemMalfunction)
 						}
 						return
-					case UploadPackBin, UploadArchiveBin:
+					case uploadPackBin, uploadArchiveBin:
 						if access < backend.ReadOnlyAccess {
 							sshFatal(s, ErrNotAuthed)
 							return
 						}
-						gitPack := UploadPack
-						if gc == UploadArchiveBin {
-							gitPack = UploadArchive
+						gitPack := uploadPack
+						if gc == uploadArchiveBin {
+							gitPack = uploadArchive
 						}
 						err := gitPack(s, s, s.Stderr(), repoDir)
 						if errors.Is(err, ErrInvalidRepo) {
@@ -166,6 +166,6 @@ func (s *SSHServer) Middleware(cfg *config.Config) wish.Middleware {
 
 // sshFatal prints to the session's STDOUT as a git response and exit 1.
 func sshFatal(s ssh.Session, v ...interface{}) {
-	WritePktline(s, v...)
+	writePktline(s, v...)
 	s.Exit(1) // nolint: errcheck
 }