ref(git): use soft serve git middleware

Ayman Bagabas created

Change summary

config/auth.go                  |   6 
config/auth_test.go             |   2 
config/config.go                |  16 +
config/git.go                   |  34 +--
server/cmd/cat.go               |   4 
server/cmd/git.go               |   4 
server/cmd/list.go              |   4 
server/cmd/reload.go            |   4 
server/git/ssh.go               | 247 +++++++++++++++++++++++++++++++++++
server/git/ssh_test.go          | 227 ++++++++++++++++++++++++++++++++
server/server.go                |   2 
ui/pages/selection/selection.go |   6 
12 files changed, 513 insertions(+), 43 deletions(-)

Detailed changes

config/auth.go 🔗

@@ -4,7 +4,7 @@ import (
 	"log"
 	"strings"
 
-	gm "github.com/charmbracelet/wish/git"
+	gm "github.com/charmbracelet/soft-serve/server/git"
 	"github.com/gliderlabs/ssh"
 	gossh "golang.org/x/crypto/ssh"
 )
@@ -45,12 +45,12 @@ func (cfg *Config) AuthRepo(repo string, pk ssh.PublicKey) gm.AccessLevel {
 
 // PasswordHandler returns whether or not password access is allowed.
 func (cfg *Config) PasswordHandler(ctx ssh.Context, password string) bool {
-	return (cfg.AnonAccess != "no-access") && cfg.AllowKeyless
+	return (cfg.AnonAccess != gm.NoAccess.String()) && cfg.AllowKeyless
 }
 
 // KeyboardInteractiveHandler returns whether or not keyboard interactive is allowed.
 func (cfg *Config) KeyboardInteractiveHandler(ctx ssh.Context, _ gossh.KeyboardInteractiveChallenge) bool {
-	return (cfg.AnonAccess != "no-access") && cfg.AllowKeyless
+	return (cfg.AnonAccess != gm.NoAccess.String()) && cfg.AllowKeyless
 }
 
 // PublicKeyHandler returns whether or not the given public key may access the

config/auth_test.go 🔗

@@ -3,7 +3,7 @@ package config
 import (
 	"testing"
 
-	"github.com/charmbracelet/wish/git"
+	"github.com/charmbracelet/soft-serve/server/git"
 	"github.com/gliderlabs/ssh"
 	"github.com/matryer/is"
 )

config/config.go 🔗

@@ -32,6 +32,10 @@ var (
 	ErrNoConfig = errors.New("no config file found")
 )
 
+const (
+	defaultConfigRepo = "config"
+)
+
 // Config is the Soft Serve configuration.
 type Config struct {
 	Name         string         `yaml:"name" json:"name"`
@@ -93,7 +97,7 @@ func NewConfig(cfg *config.Config) (*Config, error) {
 	c := &Config{
 		Cfg: cfg,
 	}
-	c.Host = cfg.Host
+	c.Host = host
 	c.Port = port
 	c.Source = rs
 	// Grant read-write access when no keys are provided.
@@ -133,7 +137,7 @@ func NewConfig(cfg *config.Config) (*Config, error) {
 // readConfig reads the config file for the repo. All config files are stored in
 // the config repo.
 func (cfg *Config) readConfig(repo string, v interface{}) error {
-	cr, err := cfg.Source.GetRepo("config")
+	cr, err := cfg.Source.GetRepo(defaultConfigRepo)
 	if err != nil {
 		return err
 	}
@@ -176,7 +180,7 @@ func (cfg *Config) Reload() error {
 	if err != nil {
 		return err
 	}
-	if err := cfg.readConfig("config", cfg); err != nil {
+	if err := cfg.readConfig(defaultConfigRepo, cfg); err != nil {
 		return fmt.Errorf("error reading config: %w", err)
 	}
 	// sanitize repo configs
@@ -187,7 +191,7 @@ func (cfg *Config) Reload() error {
 	for _, r := range cfg.Source.AllRepos() {
 		var rc RepoConfig
 		repo := r.Repo()
-		if repo == "config" {
+		if repo == defaultConfigRepo {
 			continue
 		}
 		if err := cfg.readConfig(repo, &rc); err != nil {
@@ -253,8 +257,8 @@ func createFile(path string, content string) error {
 }
 
 func (cfg *Config) createDefaultConfigRepo(yaml string) error {
-	cn := "config"
-	rp := filepath.Join(cfg.Cfg.RepoPath, cn)
+	cn := defaultConfigRepo
+	rp := filepath.Join(cfg.Cfg.RepoPath, cn) + ".git"
 	rs := cfg.Source
 	err := rs.LoadRepo(cn)
 	if errors.Is(err, fs.ErrNotExist) {

config/git.go 🔗

@@ -5,6 +5,7 @@ import (
 	"log"
 	"os"
 	"path/filepath"
+	"strings"
 	"sync"
 
 	"github.com/charmbracelet/soft-serve/git"
@@ -70,7 +71,7 @@ func (r *Repo) Repo() string {
 // Name returns the name of the repository.
 func (r *Repo) Name() string {
 	if r.name == "" {
-		return r.Repo()
+		return strings.TrimSuffix(r.Repo(), ".git")
 	}
 	return r.name
 }
@@ -205,6 +206,9 @@ func (rs *RepoSource) AllRepos() []*Repo {
 func (rs *RepoSource) GetRepo(name string) (*Repo, error) {
 	rs.mtx.Lock()
 	defer rs.mtx.Unlock()
+	if strings.HasSuffix(name, ".git") {
+		name = strings.TrimSuffix(name, ".git")
+	}
 	r, ok := rs.repos[name]
 	if !ok {
 		return nil, ErrMissingRepo
@@ -212,31 +216,19 @@ func (rs *RepoSource) GetRepo(name string) (*Repo, error) {
 	return r, nil
 }
 
-// InitRepo initializes a new Git repository.
-func (rs *RepoSource) InitRepo(name string, bare bool) (*Repo, error) {
-	rs.mtx.Lock()
-	defer rs.mtx.Unlock()
-	rp := filepath.Join(rs.Path, name)
-	rg, err := git.Init(rp, bare)
-	if err != nil {
-		return nil, err
-	}
-	r := &Repo{
-		path:       rp,
-		repository: rg,
-		refs: []*git.Reference{
-			git.NewReference(rp, git.RefsHeads+"master"),
-		},
-	}
-	rs.repos[name] = r
-	return r, nil
-}
-
 // LoadRepo loads a repository from disk.
 func (rs *RepoSource) LoadRepo(name string) error {
 	rs.mtx.Lock()
 	defer rs.mtx.Unlock()
+	if strings.HasSuffix(name, ".git") {
+		name = strings.TrimSuffix(name, ".git")
+	}
 	rp := filepath.Join(rs.Path, name)
+	if _, err := os.Stat(rp); os.IsNotExist(err) {
+		rp += ".git"
+	} else {
+		log.Printf("warning: %q should be renamed to %q", rp, rp+".git")
+	}
 	r, err := rs.open(rp)
 	if err != nil {
 		log.Printf("error opening repository %q: %s", rp, err)

server/cmd/cat.go 🔗

@@ -8,8 +8,8 @@ import (
 	gansi "github.com/charmbracelet/glamour/ansi"
 	"github.com/charmbracelet/lipgloss"
 	"github.com/charmbracelet/soft-serve/config"
+	gm "github.com/charmbracelet/soft-serve/server/git"
 	"github.com/charmbracelet/soft-serve/ui/common"
-	gitwish "github.com/charmbracelet/wish/git"
 	"github.com/muesli/termenv"
 	"github.com/spf13/cobra"
 )
@@ -37,7 +37,7 @@ func CatCommand() *cobra.Command {
 			rn := ps[0]
 			fp := strings.Join(ps[1:], "/")
 			auth := ac.AuthRepo(rn, s.PublicKey())
-			if auth < gitwish.ReadOnlyAccess {
+			if auth < gm.ReadOnlyAccess {
 				return ErrUnauthorized
 			}
 			var repo *config.Repo

server/cmd/git.go 🔗

@@ -5,7 +5,7 @@ import (
 	"os/exec"
 
 	"github.com/charmbracelet/soft-serve/config"
-	gitwish "github.com/charmbracelet/wish/git"
+	gm "github.com/charmbracelet/soft-serve/server/git"
 	"github.com/spf13/cobra"
 )
 
@@ -17,7 +17,7 @@ func GitCommand() *cobra.Command {
 		RunE: func(cmd *cobra.Command, args []string) error {
 			ac, s := fromContext(cmd)
 			auth := ac.AuthRepo("config", s.PublicKey())
-			if auth < gitwish.AdminAccess {
+			if auth < gm.AdminAccess {
 				return ErrUnauthorized
 			}
 			if len(args) < 1 {

server/cmd/list.go 🔗

@@ -6,7 +6,7 @@ import (
 	"strings"
 
 	"github.com/charmbracelet/soft-serve/git"
-	gitwish "github.com/charmbracelet/wish/git"
+	gm "github.com/charmbracelet/soft-serve/server/git"
 	"github.com/spf13/cobra"
 )
 
@@ -27,7 +27,7 @@ func ListCommand() *cobra.Command {
 				ps = strings.Split(path, "/")
 				rn = ps[0]
 				auth := ac.AuthRepo(rn, s.PublicKey())
-				if auth < gitwish.ReadOnlyAccess {
+				if auth < gm.ReadOnlyAccess {
 					return ErrUnauthorized
 				}
 			}

server/cmd/reload.go 🔗

@@ -1,7 +1,7 @@
 package cmd
 
 import (
-	gitwish "github.com/charmbracelet/wish/git"
+	gm "github.com/charmbracelet/soft-serve/server/git"
 	"github.com/spf13/cobra"
 )
 
@@ -13,7 +13,7 @@ func ReloadCommand() *cobra.Command {
 		RunE: func(cmd *cobra.Command, args []string) error {
 			ac, s := fromContext(cmd)
 			auth := ac.AuthRepo("config", s.PublicKey())
-			if auth < gitwish.AdminAccess {
+			if auth < gm.AdminAccess {
 				return ErrUnauthorized
 			}
 			return ac.Reload()

server/git/ssh.go 🔗

@@ -0,0 +1,247 @@
+package git
+
+import (
+	"errors"
+	"fmt"
+	"log"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+
+	"github.com/charmbracelet/wish"
+	"github.com/gliderlabs/ssh"
+	"github.com/go-git/go-git/v5"
+	"github.com/go-git/go-git/v5/plumbing"
+)
+
+// ErrNotAuthed represents unauthorized access.
+var ErrNotAuthed = errors.New("you are not authorized to do this")
+
+// ErrSystemMalfunction represents a general system error returned to clients.
+var ErrSystemMalfunction = errors.New("something went wrong")
+
+// ErrInvalidRepo represents an attempt to access a non-existent repo.
+var ErrInvalidRepo = errors.New("invalid repo")
+
+// AccessLevel is the level of access allowed to a repo.
+type AccessLevel int
+
+const (
+	// NoAccess does not allow access to the repo.
+	NoAccess AccessLevel = iota
+
+	// ReadOnlyAccess allows read-only access to the repo.
+	ReadOnlyAccess
+
+	// ReadWriteAccess allows read and write access to the repo.
+	ReadWriteAccess
+
+	// AdminAccess allows read, write, and admin access to the repo.
+	AdminAccess
+)
+
+// String implements the Stringer interface for AccessLevel.
+func (a AccessLevel) String() string {
+	switch a {
+	case NoAccess:
+		return "no-access"
+	case ReadOnlyAccess:
+		return "read-only"
+	case ReadWriteAccess:
+		return "read-write"
+	case AdminAccess:
+		return "admin-access"
+	default:
+		return ""
+	}
+}
+
+// Hooks is an interface that allows for custom authorization
+// implementations and post push/fetch notifications. Prior to git access,
+// AuthRepo will be called with the ssh.Session public key and the repo name.
+// Implementers return the appropriate AccessLevel.
+type Hooks interface {
+	AuthRepo(string, ssh.PublicKey) AccessLevel
+	Push(string, ssh.PublicKey)
+	Fetch(string, ssh.PublicKey)
+}
+
+// Middleware adds Git server functionality to the ssh.Server. Repos are stored
+// in the specified repo directory. The provided Hooks implementation will be
+// checked for access on a per repo basis for a ssh.Session public key.
+// Hooks.Push and Hooks.Fetch will be called on successful completion of
+// their commands.
+func Middleware(repoDir string, gh Hooks) wish.Middleware {
+	return func(sh ssh.Handler) ssh.Handler {
+		return func(s ssh.Session) {
+			cmd := s.Command()
+			if len(cmd) == 2 {
+				gc := cmd[0]
+				// repo should be in the form of "repo.git"
+				repo := strings.TrimPrefix(cmd[1], "/")
+				repo = filepath.Clean(repo)
+				if strings.Contains(repo, "/") {
+					Fatal(s, fmt.Errorf("%s: %s", ErrInvalidRepo, "user repos not supported"))
+				}
+				// git bare repositories should end in ".git"
+				// https://git-scm.com/docs/gitrepository-layout
+				if !strings.HasSuffix(repo, ".git") {
+					repo += ".git"
+				}
+				pk := s.PublicKey()
+				access := gh.AuthRepo(repo, pk)
+				switch gc {
+				case "git-receive-pack":
+					switch access {
+					case ReadWriteAccess, AdminAccess:
+						err := gitPack(s, gc, repoDir, repo)
+						if err != nil {
+							Fatal(s, ErrSystemMalfunction)
+						} else {
+							gh.Push(repo, pk)
+						}
+					default:
+						Fatal(s, ErrNotAuthed)
+					}
+					return
+				case "git-upload-archive", "git-upload-pack":
+					switch access {
+					case ReadOnlyAccess, ReadWriteAccess, AdminAccess:
+						err := gitPack(s, gc, repoDir, repo)
+						switch err {
+						case ErrInvalidRepo:
+							Fatal(s, ErrInvalidRepo)
+						case nil:
+							gh.Fetch(repo, pk)
+						default:
+							log.Printf("unknown git error: %s", err)
+							Fatal(s, ErrSystemMalfunction)
+						}
+					default:
+						Fatal(s, ErrNotAuthed)
+					}
+					return
+				}
+			}
+			sh(s)
+		}
+	}
+}
+
+func gitPack(s ssh.Session, gitCmd string, repoDir string, repo string) error {
+	cmd := strings.TrimPrefix(gitCmd, "git-")
+	rp := filepath.Join(repoDir, repo)
+	switch gitCmd {
+	case "git-upload-archive", "git-upload-pack":
+		exists, err := fileExists(rp)
+		if !exists {
+			return ErrInvalidRepo
+		}
+		if err != nil {
+			return err
+		}
+		return runGit(s, "", cmd, rp)
+	case "git-receive-pack":
+		err := ensureRepo(repoDir, repo)
+		if err != nil {
+			return err
+		}
+		err = runGit(s, "", cmd, rp)
+		if err != nil {
+			return err
+		}
+		err = ensureDefaultBranch(s, rp)
+		if err != nil {
+			return err
+		}
+		// Needed for git dumb http server
+		return runGit(s, rp, "update-server-info")
+	default:
+		return fmt.Errorf("unknown git command: %s", gitCmd)
+	}
+}
+
+func fileExists(path string) (bool, error) {
+	_, err := os.Stat(path)
+	if err == nil {
+		return true, nil
+	}
+	if os.IsNotExist(err) {
+		return false, nil
+	}
+	return true, err
+}
+
+// Fatal prints to the session's STDOUT as a git response and exit 1.
+func Fatal(s ssh.Session, v ...interface{}) {
+	msg := fmt.Sprint(v...)
+	// hex length includes 4 byte length prefix and ending newline
+	pktLine := fmt.Sprintf("%04x%s\n", len(msg)+5, msg)
+	_, _ = wish.WriteString(s, pktLine)
+	s.Exit(1) // nolint: errcheck
+}
+
+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
+	}
+	if !exists {
+		_, err := git.PlainInit(rp, true)
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func runGit(s ssh.Session, dir string, args ...string) error {
+	usi := exec.CommandContext(s.Context(), "git", args...)
+	usi.Dir = dir
+	usi.Stdout = s
+	usi.Stdin = s
+	if err := usi.Run(); err != nil {
+		return err
+	}
+	return nil
+}
+
+func ensureDefaultBranch(s ssh.Session, repoPath string) error {
+	r, err := git.PlainOpen(repoPath)
+	if err != nil {
+		return err
+	}
+	brs, err := r.Branches()
+	if err != nil {
+		return err
+	}
+	defer brs.Close()
+	fb, err := brs.Next()
+	if err != nil {
+		return err
+	}
+	// Rename the default branch to the first branch available
+	_, err = r.Head()
+	if err == plumbing.ErrReferenceNotFound {
+		err = runGit(s, repoPath, "branch", "-M", fb.Name().Short())
+		if err != nil {
+			return err
+		}
+	}
+	if err != nil && err != plumbing.ErrReferenceNotFound {
+		return err
+	}
+	return nil
+}

server/git/ssh_test.go 🔗

@@ -0,0 +1,227 @@
+package git
+
+import (
+	"fmt"
+	"net"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"sync"
+	"testing"
+
+	"github.com/charmbracelet/keygen"
+	"github.com/charmbracelet/wish"
+	"github.com/gliderlabs/ssh"
+)
+
+func TestGitMiddleware(t *testing.T) {
+	pubkey, pkPath := createKeyPair(t)
+
+	l, err := net.Listen("tcp", "127.0.0.1:0")
+	requireNoError(t, err)
+	remote := "ssh://" + l.Addr().String()
+
+	repoDir := t.TempDir()
+	hooks := &testHooks{
+		pushes:  []action{},
+		fetches: []action{},
+		access: []accessDetails{
+			{pubkey, "repo1", AdminAccess},
+			{pubkey, "repo2", AdminAccess},
+			{pubkey, "repo3", AdminAccess},
+			{pubkey, "repo4", AdminAccess},
+			{pubkey, "repo5", NoAccess},
+			{pubkey, "repo6", ReadOnlyAccess},
+			{pubkey, "repo7", AdminAccess},
+		},
+	}
+	srv, err := wish.NewServer(
+		wish.WithMiddleware(Middleware(repoDir, hooks)),
+		wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool {
+			return true
+		}),
+	)
+	requireNoError(t, err)
+	go func() { srv.Serve(l) }()
+	t.Cleanup(func() { _ = srv.Close() })
+
+	t.Run("create repo on master", func(t *testing.T) {
+		cwd := t.TempDir()
+		requireNoError(t, runGitHelper(t, pkPath, cwd, "init", "-b", "master"))
+		requireNoError(t, runGitHelper(t, pkPath, cwd, "remote", "add", "origin", remote+"/repo1"))
+		requireNoError(t, runGitHelper(t, pkPath, cwd, "commit", "--allow-empty", "-m", "initial commit"))
+		requireNoError(t, runGitHelper(t, pkPath, cwd, "push", "origin", "master"))
+		requireHasAction(t, hooks.pushes, pubkey, "repo1")
+	})
+
+	t.Run("create repo on main", func(t *testing.T) {
+		cwd := t.TempDir()
+		requireNoError(t, runGitHelper(t, pkPath, cwd, "init", "-b", "main"))
+		requireNoError(t, runGitHelper(t, pkPath, cwd, "remote", "add", "origin", remote+"/repo2"))
+		requireNoError(t, runGitHelper(t, pkPath, cwd, "commit", "--allow-empty", "-m", "initial commit"))
+		requireNoError(t, runGitHelper(t, pkPath, cwd, "push", "origin", "main"))
+		requireHasAction(t, hooks.pushes, pubkey, "repo2")
+	})
+
+	t.Run("create and clone repo", func(t *testing.T) {
+		cwd := t.TempDir()
+		requireNoError(t, runGitHelper(t, pkPath, cwd, "init", "-b", "main"))
+		requireNoError(t, runGitHelper(t, pkPath, cwd, "remote", "add", "origin", remote+"/repo3"))
+		requireNoError(t, runGitHelper(t, pkPath, cwd, "commit", "--allow-empty", "-m", "initial commit"))
+		requireNoError(t, runGitHelper(t, pkPath, cwd, "push", "origin", "main"))
+
+		cwd = t.TempDir()
+		requireNoError(t, runGitHelper(t, pkPath, cwd, "clone", remote+"/repo3"))
+
+		requireHasAction(t, hooks.pushes, pubkey, "repo3")
+		requireHasAction(t, hooks.fetches, pubkey, "repo3")
+	})
+
+	t.Run("clone repo that doesn't exist", func(t *testing.T) {
+		cwd := t.TempDir()
+		requireError(t, runGitHelper(t, pkPath, cwd, "clone", remote+"/repo4"))
+	})
+
+	t.Run("clone repo with no access", func(t *testing.T) {
+		cwd := t.TempDir()
+		requireError(t, runGitHelper(t, pkPath, cwd, "clone", remote+"/repo5"))
+	})
+
+	t.Run("push repo with with readonly", func(t *testing.T) {
+		cwd := t.TempDir()
+		requireNoError(t, runGitHelper(t, pkPath, cwd, "init", "-b", "main"))
+		requireNoError(t, runGitHelper(t, pkPath, cwd, "remote", "add", "origin", remote+"/repo6"))
+		requireNoError(t, runGitHelper(t, pkPath, cwd, "commit", "--allow-empty", "-m", "initial commit"))
+		requireError(t, runGitHelper(t, pkPath, cwd, "push", "origin", "main"))
+	})
+
+	t.Run("create and clone repo on weird branch", func(t *testing.T) {
+		cwd := t.TempDir()
+		requireNoError(t, runGitHelper(t, pkPath, cwd, "init", "-b", "a-weird-branch-name"))
+		requireNoError(t, runGitHelper(t, pkPath, cwd, "remote", "add", "origin", remote+"/repo7"))
+		requireNoError(t, runGitHelper(t, pkPath, cwd, "commit", "--allow-empty", "-m", "initial commit"))
+		requireNoError(t, runGitHelper(t, pkPath, cwd, "push", "origin", "a-weird-branch-name"))
+
+		cwd = t.TempDir()
+		requireNoError(t, runGitHelper(t, pkPath, cwd, "clone", remote+"/repo7"))
+
+		requireHasAction(t, hooks.pushes, pubkey, "repo7")
+		requireHasAction(t, hooks.fetches, pubkey, "repo7")
+	})
+}
+
+func runGitHelper(t *testing.T, pk, cwd string, args ...string) error {
+	t.Helper()
+
+	allArgs := []string{
+		"-c", "user.name='wish'",
+		"-c", "user.email='test@wish'",
+		"-c", "commit.gpgSign=false",
+		"-c", "tag.gpgSign=false",
+		"-c", "log.showSignature=false",
+		"-c", "ssh.variant=ssh",
+	}
+	allArgs = append(allArgs, args...)
+
+	cmd := exec.Command("git", allArgs...)
+	cmd.Dir = cwd
+	cmd.Env = []string{fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i %s -F /dev/null`, pk)}
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		t.Log("git out:", string(out))
+	}
+	return err
+}
+
+func requireNoError(t *testing.T, err error) {
+	t.Helper()
+
+	if err != nil {
+		t.Fatalf("expected no error, got %q", err.Error())
+	}
+}
+
+func requireError(t *testing.T, err error) {
+	t.Helper()
+
+	if err == nil {
+		t.Fatalf("expected an error, got nil")
+	}
+}
+
+func requireHasAction(t *testing.T, actions []action, key ssh.PublicKey, repo string) {
+	t.Helper()
+
+	for _, action := range actions {
+		r1 := repo
+		if !strings.HasSuffix(r1, ".git") {
+			r1 += ".git"
+		}
+		r2 := action.repo
+		if !strings.HasSuffix(r2, ".git") {
+			r2 += ".git"
+		}
+		if r1 == r2 && ssh.KeysEqual(key, action.key) {
+			return
+		}
+	}
+	t.Fatalf("expected action for %q, got none", repo)
+}
+
+func createKeyPair(t *testing.T) (ssh.PublicKey, string) {
+	t.Helper()
+
+	keyDir := t.TempDir()
+	_, err := keygen.NewWithWrite(filepath.Join(keyDir, "id"), nil, keygen.Ed25519)
+	requireNoError(t, err)
+	pk := filepath.Join(keyDir, "id_ed25519")
+	pubBytes, err := os.ReadFile(filepath.Join(keyDir, "id_ed25519.pub"))
+	requireNoError(t, err)
+	pubkey, _, _, _, err := ssh.ParseAuthorizedKey(pubBytes)
+	requireNoError(t, err)
+	return pubkey, pk
+}
+
+type accessDetails struct {
+	key   ssh.PublicKey
+	repo  string
+	level AccessLevel
+}
+
+type action struct {
+	key  ssh.PublicKey
+	repo string
+}
+
+type testHooks struct {
+	sync.Mutex
+	pushes  []action
+	fetches []action
+	access  []accessDetails
+}
+
+func (h *testHooks) AuthRepo(repo string, key ssh.PublicKey) AccessLevel {
+	for _, dets := range h.access {
+		r1 := strings.TrimSuffix(dets.repo, ".git")
+		r2 := strings.TrimSuffix(repo, ".git")
+		if r1 == r2 && ssh.KeysEqual(key, dets.key) {
+			return dets.level
+		}
+	}
+	return NoAccess
+}
+
+func (h *testHooks) Push(repo string, key ssh.PublicKey) {
+	h.Lock()
+	defer h.Unlock()
+
+	h.pushes = append(h.pushes, action{key, repo})
+}
+
+func (h *testHooks) Fetch(repo string, key ssh.PublicKey) {
+	h.Lock()
+	defer h.Unlock()
+
+	h.fetches = append(h.fetches, action{key, repo})
+}

server/server.go 🔗

@@ -10,9 +10,9 @@ import (
 
 	appCfg "github.com/charmbracelet/soft-serve/config"
 	"github.com/charmbracelet/soft-serve/server/config"
+	gm "github.com/charmbracelet/soft-serve/server/git"
 	"github.com/charmbracelet/wish"
 	bm "github.com/charmbracelet/wish/bubbletea"
-	gm "github.com/charmbracelet/wish/git"
 	lm "github.com/charmbracelet/wish/logging"
 	rm "github.com/charmbracelet/wish/recover"
 	"github.com/gliderlabs/ssh"

ui/pages/selection/selection.go 🔗

@@ -9,12 +9,12 @@ import (
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
 	"github.com/charmbracelet/soft-serve/config"
+	gm "github.com/charmbracelet/soft-serve/server/git"
 	"github.com/charmbracelet/soft-serve/ui/common"
 	"github.com/charmbracelet/soft-serve/ui/components/code"
 	"github.com/charmbracelet/soft-serve/ui/components/selector"
 	"github.com/charmbracelet/soft-serve/ui/components/tabs"
 	"github.com/charmbracelet/soft-serve/ui/git"
-	wgit "github.com/charmbracelet/wish/git"
 	"github.com/gliderlabs/ssh"
 )
 
@@ -190,7 +190,7 @@ func (s *Selection) Init() tea.Cmd {
 	// Put configured repos first
 	for _, r := range cfg.Repos {
 		acc := cfg.AuthRepo(r.Repo, pk)
-		if r.Private && acc < wgit.ReadOnlyAccess {
+		if r.Private && acc < gm.ReadOnlyAccess {
 			continue
 		}
 		repo, err := cfg.Source.GetRepo(r.Repo)
@@ -209,7 +209,7 @@ func (s *Selection) Init() tea.Cmd {
 			readmeCmd = s.readme.SetContent(rm, rp)
 		}
 		acc := cfg.AuthRepo(r.Repo(), pk)
-		if r.IsPrivate() && acc < wgit.ReadOnlyAccess {
+		if r.IsPrivate() && acc < gm.ReadOnlyAccess {
 			continue
 		}
 		exists := false