refactor,feat: use soft serve backends, implement git & http servers, remove config repository, manage soft serve from ssh (#231)

Ayman Bagabas created

* feat(backend): server backend

Add file backend that uses filesystem to manage non-git operations.

* feat(git): more git commands

* refactor(config): remove config module

* feat(gomod): upgrade wish & use charmbracelet/ssh

* feat(server): use cmd middleware

* refactor(ui): clean up and tidy

* feat(git): implement git daemon

* fix(examples): update setuid

* fix(backend.file): populate default config

* fix: tests

* refactor: tidy up files

* chore(gitignore): add more files

Change summary

.gitignore                      |   5 
cmd/soft/man.go                 |   2 
cmd/soft/serve.go               |  12 
config/auth.go                  | 155 --------
config/auth_test.go             | 669 -----------------------------------
config/config.go                | 334 -----------------
config/config_test.go           |  37 -
config/defaults.go              |  58 ---
config/git.go                   | 304 ---------------
config/testdata/k1.pub          |   1 
examples/setuid/main.go         |  13 
git/command.go                  |  11 
git/config.go                   |  51 ++
git/errors.go                   |   4 
git/repo.go                     |  40 ++
git/types.go                    |   9 
go.mod                          |  24 -
go.sum                          |  65 ---
server/backend/file/file.go     |  28 +
server/backend/noop/noop.go     | 163 ++++++++
server/backend/noop/repo.go     |  32 +
server/cmd/cat.go               | 123 ------
server/cmd/cmd.go               |  15 
server/cmd/git.go               |  54 --
server/cmd/list.go              |  83 ----
server/cmd/middleware.go        |  25 
server/cmd/reload.go            |  23 -
server/cmd/repo.go              | 408 +++++++++++++++++++++
server/config/config.go         |  93 +++-
server/daemon.go                | 299 +++++++++++++++
server/daemon_test.go           |  95 ++++
server/git.go                   | 162 ++++++++
server/git/daemon/conn.go       |  55 ++
server/middleware_test.go       |  38 -
server/server.go                | 140 +++----
server/server_test.go           | 126 +----
server/session.go               |  35 -
server/session_test.go          |  58 +-
server/ssh.go                   | 157 ++++++++
ui/common/common.go             |  75 +++
ui/common/error.go              |   9 
ui/common/utils.go              |  15 
ui/components/code/code.go      |   2 
ui/git.go                       |  25 -
ui/git/git.go                   |  42 --
ui/pages/repo/empty.go          |  45 ++
ui/pages/repo/files.go          |  41 +
ui/pages/repo/log.go            |  80 +++-
ui/pages/repo/readme.go         |  55 +
ui/pages/repo/refs.go           |  55 ++
ui/pages/repo/repo.go           | 211 +++++++---
ui/pages/selection/item.go      |  66 +++
ui/pages/selection/selection.go |  97 +---
ui/styles/styles.go             |  18 
ui/ui.go                        |  95 ++--
55 files changed, 2,350 insertions(+), 2,587 deletions(-)

Detailed changes

.gitignore πŸ”—

@@ -1,7 +1,10 @@
 cmd/soft/soft
+./soft
 .ssh
 .repos
 dist
 testdata
+data/
 completions/
-manpages/
+manpages/
+soft_serve_ed25519*

cmd/soft/man.go πŸ”—

@@ -20,7 +20,7 @@ var (
 				return err
 			}
 
-			manPage = manPage.WithSection("Copyright", "(C) 2021-2022 Charmbracelet, Inc.\n"+
+			manPage = manPage.WithSection("Copyright", "(C) 2021-2023 Charmbracelet, Inc.\n"+
 				"Released under MIT license.")
 			fmt.Println(manPage.Build(roff.NewDocument()))
 			return nil

cmd/soft/serve.go πŸ”—

@@ -2,14 +2,12 @@ package main
 
 import (
 	"context"
-	"fmt"
 	"os"
 	"os/signal"
 	"syscall"
 	"time"
 
 	"github.com/charmbracelet/log"
-
 	"github.com/charmbracelet/soft-serve/server"
 	"github.com/charmbracelet/soft-serve/server/config"
 	"github.com/spf13/cobra"
@@ -23,9 +21,14 @@ var (
 		Args:  cobra.NoArgs,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			cfg := config.DefaultConfig()
-			s := server.NewServer(cfg)
+			s, err := server.NewServer(cfg)
+			if err != nil {
+				return err
+			}
 
-			log.Print("Starting SSH server", "addr", fmt.Sprintf("%s:%d", cfg.BindAddr, cfg.Port))
+			if cfg.Debug {
+				log.SetLevel(log.DebugLevel)
+			}
 
 			done := make(chan os.Signal, 1)
 			lch := make(chan error, 1)
@@ -38,7 +41,6 @@ var (
 			signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
 			<-done
 
-			log.Print("Stopping SSH server", "addr", fmt.Sprintf("%s:%d", cfg.BindAddr, cfg.Port))
 			ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
 			defer cancel()
 			if err := s.Shutdown(ctx); err != nil {

config/auth.go πŸ”—

@@ -1,155 +0,0 @@
-package config
-
-import (
-	"strings"
-
-	"github.com/charmbracelet/log"
-
-	gm "github.com/charmbracelet/wish/git"
-	"github.com/gliderlabs/ssh"
-	gossh "golang.org/x/crypto/ssh"
-)
-
-// Push registers Git push functionality for the given repo and key.
-func (cfg *Config) Push(repo string, pk ssh.PublicKey) {
-	go func() {
-		err := cfg.Reload()
-		if err != nil {
-			log.Error("error reloading after push", "err", err)
-		}
-		if cfg.Cfg.Callbacks != nil {
-			cfg.Cfg.Callbacks.Push(repo)
-		}
-		r, err := cfg.Source.GetRepo(repo)
-		if err != nil {
-			log.Error("error getting repo after push", "err", err)
-			return
-		}
-		err = r.UpdateServerInfo()
-		if err != nil {
-			log.Error("error updating server info after push", "err", err)
-		}
-	}()
-}
-
-// Fetch registers Git fetch functionality for the given repo and key.
-func (cfg *Config) Fetch(repo string, pk ssh.PublicKey) {
-	if cfg.Cfg.Callbacks != nil {
-		cfg.Cfg.Callbacks.Fetch(repo)
-	}
-}
-
-// AuthRepo grants repo authorization to the given key.
-func (cfg *Config) AuthRepo(repo string, pk ssh.PublicKey) gm.AccessLevel {
-	return cfg.accessForKey(repo, pk)
-}
-
-// 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
-}
-
-// 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
-}
-
-// PublicKeyHandler returns whether or not the given public key may access the
-// repo.
-func (cfg *Config) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) bool {
-	return cfg.accessForKey("", pk) != gm.NoAccess
-}
-
-func (cfg *Config) anonAccessLevel() gm.AccessLevel {
-	switch cfg.AnonAccess {
-	case "no-access":
-		return gm.NoAccess
-	case "read-only":
-		return gm.ReadOnlyAccess
-	case "read-write":
-		return gm.ReadWriteAccess
-	case "admin-access":
-		return gm.AdminAccess
-	default:
-		return gm.NoAccess
-	}
-}
-
-// accessForKey returns the access level for the given repo.
-//
-// If repo doesn't exist, then access is based on user's admin privileges, or
-// config.AnonAccess.
-// If repo exists, and private, then admins and collabs are allowed access.
-// If repo exists, and not private, then access is based on config.AnonAccess.
-func (cfg *Config) accessForKey(repo string, pk ssh.PublicKey) gm.AccessLevel {
-	anon := cfg.anonAccessLevel()
-	private := cfg.isPrivate(repo)
-	// Find user
-	for _, user := range cfg.Users {
-		for _, k := range user.PublicKeys {
-			apk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(strings.TrimSpace(k)))
-			if err != nil {
-				log.Error("malformed authorized key", "key", k)
-				return gm.NoAccess
-			}
-			if ssh.KeysEqual(pk, apk) {
-				if user.Admin {
-					return gm.AdminAccess
-				}
-				u := user
-				if cfg.isCollab(repo, &u) {
-					if anon > gm.ReadWriteAccess {
-						return anon
-					}
-					return gm.ReadWriteAccess
-				}
-				if !private {
-					if anon > gm.ReadOnlyAccess {
-						return anon
-					}
-					return gm.ReadOnlyAccess
-				}
-			}
-		}
-	}
-	// Don't restrict access to private repos if no users are configured.
-	// Return anon access level.
-	if private && len(cfg.Users) > 0 {
-		return gm.NoAccess
-	}
-	return anon
-}
-
-func (cfg *Config) findRepo(repo string) *RepoConfig {
-	for _, r := range cfg.Repos {
-		if r.Repo == repo {
-			return &r
-		}
-	}
-	return nil
-}
-
-func (cfg *Config) isPrivate(repo string) bool {
-	if r := cfg.findRepo(repo); r != nil {
-		return r.Private
-	}
-	return false
-}
-
-func (cfg *Config) isCollab(repo string, user *User) bool {
-	if user != nil {
-		for _, r := range user.CollabRepos {
-			if r == repo {
-				return true
-			}
-		}
-		if r := cfg.findRepo(repo); r != nil {
-			for _, c := range r.Collabs {
-				if c == user.Name {
-					return true
-				}
-			}
-		}
-	}
-	return false
-}

config/auth_test.go πŸ”—

@@ -1,669 +0,0 @@
-package config
-
-import (
-	"testing"
-
-	"github.com/charmbracelet/wish/git"
-	"github.com/gliderlabs/ssh"
-	"github.com/matryer/is"
-)
-
-func TestAuth(t *testing.T) {
-	adminKey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH a@b"
-	adminPk, _, _, _, _ := ssh.ParseAuthorizedKey([]byte(adminKey))
-	dummyKey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b"
-	dummyPk, _, _, _, _ := ssh.ParseAuthorizedKey([]byte(dummyKey))
-	cases := []struct {
-		name   string
-		cfg    Config
-		repo   string
-		key    ssh.PublicKey
-		access git.AccessLevel
-	}{
-		// Repo access
-		{
-			name:   "anon access: no-access, anonymous user",
-			access: git.NoAccess,
-			repo:   "foo",
-			cfg: Config{
-				AnonAccess: "no-access",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: no-access, anonymous user with admin user",
-			access: git.NoAccess,
-			repo:   "foo",
-			cfg: Config{
-				AnonAccess: "no-access",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-					},
-				},
-				Users: []User{
-					{
-						Admin: true,
-						PublicKeys: []string{
-							adminKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: no-access, authd user",
-			key:    dummyPk,
-			repo:   "foo",
-			access: git.ReadOnlyAccess,
-			cfg: Config{
-				AnonAccess: "no-access",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-					},
-				},
-				Users: []User{
-					{
-						PublicKeys: []string{
-							dummyKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: no-access, anonymous user with admin user",
-			key:    dummyPk,
-			repo:   "foo",
-			access: git.NoAccess,
-			cfg: Config{
-				AnonAccess: "no-access",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-					},
-				},
-				Users: []User{
-					{
-						Admin: true,
-						PublicKeys: []string{
-							adminKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: no-access, admin user",
-			repo:   "foo",
-			key:    adminPk,
-			access: git.AdminAccess,
-			cfg: Config{
-				AnonAccess: "no-access",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-					},
-				},
-				Users: []User{
-					{
-						Admin: true,
-						PublicKeys: []string{
-							adminKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: read-only, anonymous user",
-			repo:   "foo",
-			access: git.ReadOnlyAccess,
-			cfg: Config{
-				AnonAccess: "read-only",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: read-only, authd user",
-			repo:   "foo",
-			key:    dummyPk,
-			access: git.ReadOnlyAccess,
-			cfg: Config{
-				AnonAccess: "read-only",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-					},
-				},
-				Users: []User{
-					{
-						PublicKeys: []string{
-							dummyKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: read-only, admin user",
-			repo:   "foo",
-			key:    adminPk,
-			access: git.AdminAccess,
-			cfg: Config{
-				AnonAccess: "read-only",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-					},
-				},
-				Users: []User{
-					{
-						Admin: true,
-						PublicKeys: []string{
-							adminKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: read-write, anonymous user",
-			repo:   "foo",
-			access: git.ReadWriteAccess,
-			cfg: Config{
-				AnonAccess: "read-write",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: read-write, authd user",
-			repo:   "foo",
-			key:    dummyPk,
-			access: git.ReadWriteAccess,
-			cfg: Config{
-				AnonAccess: "read-write",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-					},
-				},
-				Users: []User{
-					{
-						PublicKeys: []string{
-							dummyKey,
-						},
-					},
-				},
-			},
-		}, {
-			name:   "anon access: read-write, admin user",
-			repo:   "foo",
-			key:    adminPk,
-			access: git.AdminAccess,
-			cfg: Config{
-				AnonAccess: "read-write",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-					},
-				},
-				Users: []User{
-					{
-						Admin: true,
-						PublicKeys: []string{
-							adminKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: admin-access, anonymous user",
-			repo:   "foo",
-			access: git.AdminAccess,
-			cfg: Config{
-				AnonAccess: "admin-access",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: admin-access, authd user",
-			repo:   "foo",
-			key:    dummyPk,
-			access: git.AdminAccess,
-			cfg: Config{
-				AnonAccess: "admin-access",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-					},
-				},
-				Users: []User{
-					{
-						PublicKeys: []string{
-							dummyKey,
-						},
-					},
-				},
-			},
-		}, {
-			name:   "anon access: admin-access, admin user",
-			repo:   "foo",
-			key:    adminPk,
-			access: git.AdminAccess,
-			cfg: Config{
-				AnonAccess: "admin-access",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-					},
-				},
-				Users: []User{
-					{
-						Admin: true,
-						PublicKeys: []string{
-							adminKey,
-						},
-					},
-				},
-			},
-		},
-
-		// Collabs
-		{
-			name:   "anon access: no-access, authd user, collab",
-			key:    dummyPk,
-			repo:   "foo",
-			access: git.ReadWriteAccess,
-			cfg: Config{
-				AnonAccess: "no-access",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-						Collabs: []string{
-							"user",
-						},
-					},
-				},
-				Users: []User{
-					{
-						Name: "user",
-						PublicKeys: []string{
-							dummyKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: no-access, authd user, collab, private repo",
-			key:    dummyPk,
-			repo:   "foo",
-			access: git.ReadWriteAccess,
-			cfg: Config{
-				AnonAccess: "no-access",
-				Repos: []RepoConfig{
-					{
-						Repo:    "foo",
-						Private: true,
-						Collabs: []string{
-							"user",
-						},
-					},
-				},
-				Users: []User{
-					{
-						Name: "user",
-						PublicKeys: []string{
-							dummyKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: no-access, admin user, collab, private repo",
-			repo:   "foo",
-			key:    adminPk,
-			access: git.AdminAccess,
-			cfg: Config{
-				AnonAccess: "no-access",
-				Repos: []RepoConfig{
-					{
-						Repo:    "foo",
-						Private: true,
-						Collabs: []string{
-							"user",
-						},
-					},
-				},
-				Users: []User{
-					{
-						Name:  "admin",
-						Admin: true,
-						PublicKeys: []string{
-							adminKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: read-only, authd user, collab, private repo",
-			repo:   "foo",
-			key:    dummyPk,
-			access: git.ReadWriteAccess,
-			cfg: Config{
-				AnonAccess: "read-only",
-				Repos: []RepoConfig{
-					{
-						Repo:    "foo",
-						Private: true,
-						Collabs: []string{
-							"user",
-						},
-					},
-				},
-				Users: []User{
-					{
-						Name: "user",
-						PublicKeys: []string{
-							dummyKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: admin-access, anonymous user, collab",
-			repo:   "foo",
-			access: git.AdminAccess,
-			cfg: Config{
-				AnonAccess: "admin-access",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-						Collabs: []string{
-							"user",
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: admin-access, authd user, collab",
-			repo:   "foo",
-			key:    dummyPk,
-			access: git.AdminAccess,
-			cfg: Config{
-				AnonAccess: "admin-access",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-						Collabs: []string{
-							"user",
-						},
-					},
-				},
-				Users: []User{
-					{
-						Name: "user",
-						PublicKeys: []string{
-							dummyKey,
-						},
-					},
-				},
-			},
-		}, {
-			name:   "anon access: admin-access, admin user, collab",
-			repo:   "foo",
-			key:    adminPk,
-			access: git.AdminAccess,
-			cfg: Config{
-				AnonAccess: "admin-access",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-						Collabs: []string{
-							"user",
-						},
-					},
-				},
-				Users: []User{
-					{
-						Name:  "admin",
-						Admin: true,
-						PublicKeys: []string{
-							adminKey,
-						},
-					},
-				},
-			},
-		},
-
-		// New repo
-		{
-			name:   "anon access: no-access, anonymous user, new repo",
-			access: git.NoAccess,
-			repo:   "foo",
-			cfg: Config{
-				AnonAccess: "no-access",
-			},
-		},
-		{
-			name:   "anon access: no-access, authd user, new repo",
-			key:    dummyPk,
-			repo:   "foo",
-			access: git.ReadOnlyAccess,
-			cfg: Config{
-				AnonAccess: "no-access",
-				Users: []User{
-					{
-						PublicKeys: []string{
-							dummyKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: no-access, authd user, new repo, with user",
-			key:    dummyPk,
-			repo:   "foo",
-			access: git.NoAccess,
-			cfg: Config{
-				AnonAccess: "no-access",
-				Users: []User{
-					{
-						PublicKeys: []string{
-							adminKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: no-access, admin user, new repo",
-			repo:   "foo",
-			key:    adminPk,
-			access: git.AdminAccess,
-			cfg: Config{
-				AnonAccess: "no-access",
-				Users: []User{
-					{
-						Admin: true,
-						PublicKeys: []string{
-							adminKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: read-only, anonymous user, new repo",
-			repo:   "foo",
-			access: git.ReadOnlyAccess,
-			cfg: Config{
-				AnonAccess: "read-only",
-			},
-		},
-		{
-			name:   "anon access: read-only, authd user, new repo",
-			repo:   "foo",
-			key:    dummyPk,
-			access: git.ReadOnlyAccess,
-			cfg: Config{
-				AnonAccess: "read-only",
-				Users: []User{
-					{
-						PublicKeys: []string{
-							dummyKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: read-only, admin user, new repo",
-			repo:   "foo",
-			key:    adminPk,
-			access: git.AdminAccess,
-			cfg: Config{
-				AnonAccess: "read-only",
-				Users: []User{
-					{
-						Admin: true,
-						PublicKeys: []string{
-							adminKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: read-write, anonymous user, new repo",
-			repo:   "foo",
-			access: git.ReadWriteAccess,
-			cfg: Config{
-				AnonAccess: "read-write",
-			},
-		},
-		{
-			name:   "anon access: read-write, authd user, new repo",
-			repo:   "foo",
-			key:    dummyPk,
-			access: git.ReadWriteAccess,
-			cfg: Config{
-				AnonAccess: "read-write",
-				Users: []User{
-					{
-						PublicKeys: []string{
-							dummyKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: read-write, admin user, new repo",
-			repo:   "foo",
-			key:    adminPk,
-			access: git.AdminAccess,
-			cfg: Config{
-				AnonAccess: "read-write",
-				Users: []User{
-					{
-						Admin: true,
-						PublicKeys: []string{
-							adminKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: admin-access, anonymous user, new repo",
-			repo:   "foo",
-			access: git.AdminAccess,
-			cfg: Config{
-				AnonAccess: "admin-access",
-			},
-		},
-		{
-			name:   "anon access: admin-access, authd user, new repo",
-			repo:   "foo",
-			key:    dummyPk,
-			access: git.AdminAccess,
-			cfg: Config{
-				AnonAccess: "admin-access",
-				Users: []User{
-					{
-						PublicKeys: []string{
-							dummyKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: admin-access, admin user, new repo",
-			repo:   "foo",
-			key:    adminPk,
-			access: git.AdminAccess,
-			cfg: Config{
-				AnonAccess: "admin-access",
-				Users: []User{
-					{
-						Admin: true,
-						PublicKeys: []string{
-							adminKey,
-						},
-					},
-				},
-			},
-		},
-
-		// No users
-		{
-			name:   "anon access: read-only, no users",
-			repo:   "foo",
-			access: git.ReadOnlyAccess,
-			cfg: Config{
-				AnonAccess: "read-only",
-			},
-		},
-		{
-			name:   "anon access: read-write, no users",
-			repo:   "foo",
-			access: git.ReadWriteAccess,
-			cfg: Config{
-				AnonAccess: "read-write",
-			},
-		},
-	}
-	for _, c := range cases {
-		t.Run(c.name, func(t *testing.T) {
-			is := is.New(t)
-			al := c.cfg.accessForKey(c.repo, c.key)
-			is.Equal(al, c.access)
-		})
-	}
-}

config/config.go πŸ”—

@@ -1,334 +0,0 @@
-package config
-
-import (
-	"bytes"
-	"encoding/json"
-	"errors"
-	"io/fs"
-	"path/filepath"
-	"strings"
-	"sync"
-	"text/template"
-	"time"
-
-	"github.com/charmbracelet/log"
-
-	"golang.org/x/crypto/ssh"
-	"gopkg.in/yaml.v3"
-
-	"fmt"
-	"os"
-
-	"github.com/charmbracelet/soft-serve/git"
-	"github.com/charmbracelet/soft-serve/server/config"
-	"github.com/go-git/go-billy/v5/memfs"
-	ggit "github.com/go-git/go-git/v5"
-	"github.com/go-git/go-git/v5/plumbing/object"
-	"github.com/go-git/go-git/v5/plumbing/transport"
-	"github.com/go-git/go-git/v5/storage/memory"
-)
-
-var (
-	// ErrNoConfig is returned when a repo has no config file.
-	ErrNoConfig = errors.New("no config file found")
-)
-
-// Config is the Soft Serve configuration.
-type Config struct {
-	Name         string         `yaml:"name" json:"name"`
-	Host         string         `yaml:"host" json:"host"`
-	Port         int            `yaml:"port" json:"port"`
-	AnonAccess   string         `yaml:"anon-access" json:"anon-access"`
-	AllowKeyless bool           `yaml:"allow-keyless" json:"allow-keyless"`
-	Users        []User         `yaml:"users" json:"users"`
-	Repos        []RepoConfig   `yaml:"repos" json:"repos"`
-	Source       *RepoSource    `yaml:"-" json:"-"`
-	Cfg          *config.Config `yaml:"-" json:"-"`
-	mtx          sync.Mutex
-}
-
-// User contains user-level configuration for a repository.
-type User struct {
-	Name        string   `yaml:"name" json:"name"`
-	Admin       bool     `yaml:"admin" json:"admin"`
-	PublicKeys  []string `yaml:"public-keys" json:"public-keys"`
-	CollabRepos []string `yaml:"collab-repos" json:"collab-repos"`
-}
-
-// RepoConfig is a repository configuration.
-type RepoConfig struct {
-	Name    string   `yaml:"name" json:"name"`
-	Repo    string   `yaml:"repo" json:"repo"`
-	Note    string   `yaml:"note" json:"note"`
-	Private bool     `yaml:"private" json:"private"`
-	Readme  string   `yaml:"readme" json:"readme"`
-	Collabs []string `yaml:"collabs" json:"collabs"`
-}
-
-// NewConfig creates a new internal Config struct.
-func NewConfig(cfg *config.Config) (*Config, error) {
-	var anonAccess string
-	var yamlUsers string
-	var displayHost string
-	host := cfg.Host
-	port := cfg.Port
-
-	pks := make([]string, 0)
-	for _, k := range cfg.InitialAdminKeys {
-		if bts, err := os.ReadFile(k); err == nil {
-			// pk is a file, set its contents as pk
-			k = string(bts)
-		}
-		var pk = strings.TrimSpace(k)
-		if pk == "" {
-			continue
-		}
-		// it is a valid ssh key, nothing to do
-		if _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk)); err != nil {
-			return nil, fmt.Errorf("invalid initial admin key %q: %w", k, err)
-		}
-		pks = append(pks, pk)
-	}
-
-	rs := NewRepoSource(cfg.RepoPath)
-	c := &Config{
-		Cfg: cfg,
-	}
-	c.Host = cfg.Host
-	c.Port = port
-	c.Source = rs
-	// Grant read-write access when no keys are provided.
-	if len(pks) == 0 {
-		anonAccess = "read-write"
-	} else {
-		anonAccess = "read-only"
-	}
-	if host == "" {
-		displayHost = "localhost"
-	} else {
-		displayHost = host
-	}
-	yamlConfig := fmt.Sprintf(defaultConfig,
-		displayHost,
-		port,
-		anonAccess,
-		len(pks) == 0,
-	)
-	if len(pks) == 0 {
-		yamlUsers = defaultUserConfig
-	} else {
-		var result string
-		for _, pk := range pks {
-			result += fmt.Sprintf("      - %s\n", pk)
-		}
-		yamlUsers = fmt.Sprintf(hasKeyUserConfig, result)
-	}
-	yaml := fmt.Sprintf("%s%s%s", yamlConfig, yamlUsers, exampleUserConfig)
-	err := c.createDefaultConfigRepo(yaml)
-	if err != nil {
-		return nil, err
-	}
-	return c, nil
-}
-
-// 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")
-	if err != nil {
-		return err
-	}
-	// Parse YAML files
-	var cy string
-	for _, ext := range []string{".yaml", ".yml"} {
-		cy, _, err = cr.LatestFile(repo + ext)
-		if err != nil && !errors.Is(err, git.ErrFileNotFound) {
-			return err
-		} else if err == nil {
-			break
-		}
-	}
-	// Parse JSON files
-	cj, _, err := cr.LatestFile(repo + ".json")
-	if err != nil && !errors.Is(err, git.ErrFileNotFound) {
-		return err
-	}
-	if cy != "" {
-		err = yaml.Unmarshal([]byte(cy), v)
-		if err != nil {
-			return err
-		}
-	} else if cj != "" {
-		err = json.Unmarshal([]byte(cj), v)
-		if err != nil {
-			return err
-		}
-	} else {
-		return ErrNoConfig
-	}
-	return nil
-}
-
-// Reload reloads the configuration.
-func (cfg *Config) Reload() error {
-	cfg.mtx.Lock()
-	defer cfg.mtx.Unlock()
-	err := cfg.Source.LoadRepos()
-	if err != nil {
-		return err
-	}
-	if err := cfg.readConfig("config", cfg); err != nil {
-		return fmt.Errorf("error reading config: %w", err)
-	}
-	// sanitize repo configs
-	repos := make(map[string]RepoConfig, 0)
-	for _, r := range cfg.Repos {
-		repos[r.Repo] = r
-	}
-	for _, r := range cfg.Source.AllRepos() {
-		var rc RepoConfig
-		repo := r.Repo()
-		if repo == "config" {
-			continue
-		}
-		if err := cfg.readConfig(repo, &rc); err != nil {
-			if !errors.Is(err, ErrNoConfig) {
-				log.Error("error reading config", "err", err)
-			}
-			continue
-		}
-		repos[r.Repo()] = rc
-	}
-	cfg.Repos = make([]RepoConfig, 0, len(repos))
-	for n, r := range repos {
-		r.Repo = n
-		cfg.Repos = append(cfg.Repos, r)
-	}
-	// Populate readmes and descriptions
-	for _, r := range cfg.Source.AllRepos() {
-		repo := r.Repo()
-		err = r.UpdateServerInfo()
-		if err != nil {
-			log.Error("error updating server info", "repo", repo, "err", err)
-		}
-		pat := "README*"
-		rp := ""
-		for _, rr := range cfg.Repos {
-			if repo == rr.Repo {
-				rp = rr.Readme
-				r.name = rr.Name
-				r.description = rr.Note
-				r.private = rr.Private
-				break
-			}
-		}
-		if rp != "" {
-			pat = rp
-		}
-		rm := ""
-		fc, fp, _ := r.LatestFile(pat)
-		rm = fc
-		if repo == "config" {
-			md, err := templatize(rm, cfg)
-			if err != nil {
-				return err
-			}
-			rm = md
-		}
-		r.SetReadme(rm, fp)
-	}
-	return nil
-}
-
-func createFile(path string, content string) error {
-	f, err := os.Create(path)
-	if err != nil {
-		return err
-	}
-	defer f.Close()
-	_, err = f.WriteString(content)
-	if err != nil {
-		return err
-	}
-	return f.Sync()
-}
-
-func (cfg *Config) createDefaultConfigRepo(yaml string) error {
-	cn := "config"
-	rp := filepath.Join(cfg.Cfg.RepoPath, cn)
-	rs := cfg.Source
-	err := rs.LoadRepo(cn)
-	if errors.Is(err, fs.ErrNotExist) {
-		repo, err := ggit.PlainInit(rp, true)
-		if err != nil {
-			return err
-		}
-		repo, err = ggit.Clone(memory.NewStorage(), memfs.New(), &ggit.CloneOptions{
-			URL: rp,
-		})
-		if err != nil && err != transport.ErrEmptyRemoteRepository {
-			return err
-		}
-		wt, err := repo.Worktree()
-		if err != nil {
-			return err
-		}
-		rm, err := wt.Filesystem.Create("README.md")
-		if err != nil {
-			return err
-		}
-		_, err = rm.Write([]byte(defaultReadme))
-		if err != nil {
-			return err
-		}
-		_, err = wt.Add("README.md")
-		if err != nil {
-			return err
-		}
-		cf, err := wt.Filesystem.Create("config.yaml")
-		if err != nil {
-			return err
-		}
-		_, err = cf.Write([]byte(yaml))
-		if err != nil {
-			return err
-		}
-		_, err = wt.Add("config.yaml")
-		if err != nil {
-			return err
-		}
-		author := object.Signature{
-			Name:  "Soft Serve Server",
-			Email: "vt100@charm.sh",
-			When:  time.Now(),
-		}
-		_, err = wt.Commit("Default init", &ggit.CommitOptions{
-			All:       true,
-			Author:    &author,
-			Committer: &author,
-		})
-		if err != nil {
-			return err
-		}
-		err = repo.Push(&ggit.PushOptions{})
-		if err != nil {
-			return err
-		}
-	} else if err != nil {
-		return err
-	}
-	return cfg.Reload()
-}
-
-func templatize(mdt string, tmpl interface{}) (string, error) {
-	t, err := template.New("readme").Parse(mdt)
-	if err != nil {
-		return "", err
-	}
-	buf := &bytes.Buffer{}
-	err = t.Execute(buf, tmpl)
-	if err != nil {
-		return "", err
-	}
-	return buf.String(), nil
-}

config/config_test.go πŸ”—

@@ -1,37 +0,0 @@
-package config
-
-import (
-	"testing"
-
-	"github.com/charmbracelet/soft-serve/server/config"
-	"github.com/matryer/is"
-)
-
-func TestMultipleInitialKeys(t *testing.T) {
-	cfg, err := NewConfig(&config.Config{
-		RepoPath: t.TempDir(),
-		KeyPath:  t.TempDir(),
-		InitialAdminKeys: []string{
-			"testdata/k1.pub",
-			"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b",
-		},
-	})
-	is := is.New(t)
-	is.NoErr(err)
-	err = cfg.Reload()
-	is.NoErr(err)
-	is.Equal(cfg.Users[0].PublicKeys, []string{
-		"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH a@b",
-		"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b",
-	}) // should have both keys
-}
-
-func TestEmptyInitialKeys(t *testing.T) {
-	cfg, err := NewConfig(&config.Config{
-		RepoPath: t.TempDir(),
-		KeyPath:  t.TempDir(),
-	})
-	is := is.New(t)
-	is.NoErr(err)
-	is.Equal(len(cfg.Users), 0) // should not have any users
-}

config/defaults.go πŸ”—

@@ -1,58 +0,0 @@
-package config
-
-const defaultReadme = "# Soft Serve\n\n Welcome! You can configure your Soft Serve server by cloning this repo and pushing changes.\n\n```\ngit clone ssh://{{.Host}}:{{.Port}}/config\n```"
-
-const defaultConfig = `# The name of the server to show in the TUI.
-name: Soft Serve
-
-# The host and port to display in the TUI. You may want to change this if your
-# server is accessible from a different host and/or port that what it's
-# actually listening on (for example, if it's behind a reverse proxy).
-host: %s
-port: %d
-
-# Access level for anonymous users. Options are: admin-access, read-write,
-# read-only, and no-access.
-anon-access: %s
-
-# You can grant read-only access to users without private keys. Any password
-# will be accepted.
-allow-keyless: %t
-
-# Customize repo display in the menu.
-repos:
-  - name: Home
-    repo: config
-    private: true
-    note: "Configuration and content repo for this server"
-    readme: README.md
-`
-
-const hasKeyUserConfig = `
-
-# Authorized users. Admins have full access to all repos. Private repos are only
-# accessible by admins and collab users. Regular users can read public repos
-# based on your anon-access setting.
-users:
-  - name: Admin
-    admin: true
-    public-keys:
-%s
-`
-
-const defaultUserConfig = `
-# users:
-#   - name: Admin
-#     admin: true
-#     public-keys:
-#       - ssh-ed25519 AAAA... # redacted
-#       - ssh-rsa AAAAB3Nz... # redacted`
-
-const exampleUserConfig = `
-#   - name: Example User
-#     collab-repos:
-#       - REPO
-#     public-keys:
-#       - ssh-ed25519 AAAA... # redacted
-#       - ssh-rsa AAAAB3Nz... # redacted
-`

config/git.go πŸ”—

@@ -1,304 +0,0 @@
-package config
-
-import (
-	"errors"
-	"os"
-	"path/filepath"
-	"sync"
-
-	"github.com/charmbracelet/log"
-
-	"github.com/charmbracelet/soft-serve/git"
-	"github.com/gobwas/glob"
-	"github.com/golang/groupcache/lru"
-)
-
-// ErrMissingRepo indicates that the requested repository could not be found.
-var ErrMissingRepo = errors.New("missing repo")
-
-// Repo represents a Git repository.
-type Repo struct {
-	name        string
-	description string
-	path        string
-	repository  *git.Repository
-	readme      string
-	readmePath  string
-	head        *git.Reference
-	headCommit  string
-	refs        []*git.Reference
-	patchCache  *lru.Cache
-	private     bool
-}
-
-// open opens a Git repository.
-func (rs *RepoSource) open(path string) (*Repo, error) {
-	rg, err := git.Open(path)
-	if err != nil {
-		return nil, err
-	}
-	r := &Repo{
-		path:       path,
-		repository: rg,
-		patchCache: lru.New(1000),
-	}
-	_, err = r.HEAD()
-	if err != nil {
-		return nil, err
-	}
-	_, err = r.References()
-	if err != nil {
-		return nil, err
-	}
-	return r, nil
-}
-
-// IsPrivate returns true if the repository is private.
-func (r *Repo) IsPrivate() bool {
-	return r.private
-}
-
-// Path returns the path to the repository.
-func (r *Repo) Path() string {
-	return r.path
-}
-
-// Repo returns the repository directory name.
-func (r *Repo) Repo() string {
-	return filepath.Base(r.path)
-}
-
-// Name returns the name of the repository.
-func (r *Repo) Name() string {
-	if r.name == "" {
-		return r.Repo()
-	}
-	return r.name
-}
-
-// Description returns the description for a repository.
-func (r *Repo) Description() string {
-	return r.description
-}
-
-// Readme returns the readme and its path for the repository.
-func (r *Repo) Readme() (readme string, path string) {
-	return r.readme, r.readmePath
-}
-
-// SetReadme sets the readme for the repository.
-func (r *Repo) SetReadme(readme, path string) {
-	r.readme = readme
-	r.readmePath = path
-}
-
-// HEAD returns the reference for a repository.
-func (r *Repo) HEAD() (*git.Reference, error) {
-	if r.head != nil {
-		return r.head, nil
-	}
-	h, err := r.repository.HEAD()
-	if err != nil {
-		return nil, err
-	}
-	r.head = h
-	return h, nil
-}
-
-// GetReferences returns the references for a repository.
-func (r *Repo) References() ([]*git.Reference, error) {
-	if r.refs != nil {
-		return r.refs, nil
-	}
-	refs, err := r.repository.References()
-	if err != nil {
-		return nil, err
-	}
-	r.refs = refs
-	return refs, nil
-}
-
-// Tree returns the git tree for a given path.
-func (r *Repo) Tree(ref *git.Reference, path string) (*git.Tree, error) {
-	return r.repository.TreePath(ref, path)
-}
-
-// Diff returns the diff for a given commit.
-func (r *Repo) Diff(commit *git.Commit) (*git.Diff, error) {
-	hash := commit.Hash.String()
-	c, ok := r.patchCache.Get(hash)
-	if ok {
-		return c.(*git.Diff), nil
-	}
-	diff, err := r.repository.Diff(commit)
-	if err != nil {
-		return nil, err
-	}
-	r.patchCache.Add(hash, diff)
-	return diff, nil
-}
-
-// CountCommits returns the number of commits for a repository.
-func (r *Repo) CountCommits(ref *git.Reference) (int64, error) {
-	tc, err := r.repository.CountCommits(ref)
-	if err != nil {
-		return 0, err
-	}
-	return tc, nil
-}
-
-// Commit returns the commit for a given hash.
-func (r *Repo) Commit(hash string) (*git.Commit, error) {
-	if hash == "HEAD" && r.headCommit != "" {
-		hash = r.headCommit
-	}
-	c, err := r.repository.CatFileCommit(hash)
-	if err != nil {
-		return nil, err
-	}
-	r.headCommit = c.ID.String()
-	return &git.Commit{
-		Commit: c,
-		Hash:   git.Hash(c.ID.String()),
-	}, nil
-}
-
-// CommitsByPage returns the commits for a repository.
-func (r *Repo) CommitsByPage(ref *git.Reference, page, size int) (git.Commits, error) {
-	return r.repository.CommitsByPage(ref, page, size)
-}
-
-// Push pushes the repository to the remote.
-func (r *Repo) Push(remote, branch string) error {
-	return r.repository.Push(remote, branch)
-}
-
-// RepoSource is a reference to an on-disk repositories.
-type RepoSource struct {
-	Path  string
-	mtx   sync.Mutex
-	repos map[string]*Repo
-}
-
-// NewRepoSource creates a new RepoSource.
-func NewRepoSource(repoPath string) *RepoSource {
-	err := os.MkdirAll(repoPath, os.ModeDir|os.FileMode(0700))
-	if err != nil {
-		log.Fatal(err)
-	}
-	rs := &RepoSource{Path: repoPath}
-	rs.repos = make(map[string]*Repo, 0)
-	return rs
-}
-
-// AllRepos returns all repositories for the given RepoSource.
-func (rs *RepoSource) AllRepos() []*Repo {
-	rs.mtx.Lock()
-	defer rs.mtx.Unlock()
-	repos := make([]*Repo, 0, len(rs.repos))
-	for _, r := range rs.repos {
-		repos = append(repos, r)
-	}
-	return repos
-}
-
-// GetRepo returns a repository by name.
-func (rs *RepoSource) GetRepo(name string) (*Repo, error) {
-	rs.mtx.Lock()
-	defer rs.mtx.Unlock()
-	r, ok := rs.repos[name]
-	if !ok {
-		return nil, ErrMissingRepo
-	}
-	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()
-	rp := filepath.Join(rs.Path, name)
-	r, err := rs.open(rp)
-	if err != nil {
-		return err
-	}
-	rs.repos[name] = r
-	return nil
-}
-
-// LoadRepos opens Git repositories.
-func (rs *RepoSource) LoadRepos() error {
-	rd, err := os.ReadDir(rs.Path)
-	if err != nil {
-		return err
-	}
-	for _, de := range rd {
-		if !de.IsDir() {
-			log.Warn("not a directory", "path", filepath.Join(rs.Path, de.Name()))
-			continue
-		}
-		err = rs.LoadRepo(de.Name())
-		if err == git.ErrNotAGitRepository {
-			continue
-		}
-		if err != nil {
-			log.Warn("error loading repository", "path", filepath.Join(rs.Path, de.Name()), "err", err)
-			continue
-		}
-	}
-	return nil
-}
-
-// LatestFile returns the contents of the latest file at the specified path in
-// the repository and its file path.
-func (r *Repo) LatestFile(pattern string) (string, string, error) {
-	g := glob.MustCompile(pattern)
-	dir := filepath.Dir(pattern)
-	t, err := r.repository.TreePath(r.head, dir)
-	if err != nil {
-		return "", "", err
-	}
-	ents, err := t.Entries()
-	if err != nil {
-		return "", "", err
-	}
-	for _, e := range ents {
-		fp := filepath.Join(dir, e.Name())
-		if e.IsTree() {
-			continue
-		}
-		if g.Match(fp) {
-			bts, err := e.Contents()
-			if err != nil {
-				return "", "", err
-			}
-			return string(bts), fp, nil
-		}
-	}
-	return "", "", git.ErrFileNotFound
-}
-
-// UpdateServerInfo updates the server info for the repository.
-func (r *Repo) UpdateServerInfo() error {
-	return r.repository.UpdateServerInfo()
-}

examples/setuid/main.go πŸ”—

@@ -45,22 +45,25 @@ func main() {
 		log.Fatal("Setuid error", "err", err)
 	}
 	cfg := config.DefaultConfig()
-	cfg.Port = *port
-	s := server.NewServer(cfg)
+	cfg.SSH.ListenAddr = fmt.Sprintf(":%d", *port)
+	s, err := server.NewServer(cfg)
+	if err != nil {
+		log.Fatal(err)
+	}
 
 	done := make(chan os.Signal, 1)
 	signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
 
-	log.Print("Starting SSH server", "addr", fmt.Sprintf("%s:%d", cfg.BindAddr, cfg.Port))
+	log.Print("Starting SSH server", "addr", cfg.SSH.ListenAddr)
 	go func() {
-		if err := s.Serve(ls); err != nil {
+		if err := s.SSHServer.Serve(ls); err != nil {
 			log.Fatal(err)
 		}
 	}()
 
 	<-done
 
-	log.Print("Stopping SSH server", fmt.Sprintf("%s:%d", cfg.BindAddr, cfg.Port))
+	log.Print("Stopping SSH server", "addr", cfg.SSH.ListenAddr)
 	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
 	defer func() { cancel() }()
 	if err := s.Shutdown(ctx); err != nil {

git/command.go πŸ”—

@@ -0,0 +1,11 @@
+package git
+
+import "github.com/gogs/git-module"
+
+// RunInDirOptions are options for RunInDir.
+type RunInDirOptions = git.RunInDirOptions
+
+// NewCommand creates a new git command.
+func NewCommand(args ...string) *git.Command {
+	return git.NewCommand(args...)
+}

git/config.go πŸ”—

@@ -0,0 +1,51 @@
+package git
+
+// ConfigOptions are options for Config.
+type ConfigOptions struct {
+	File string
+	All  bool
+	Add  bool
+	CommandOptions
+}
+
+// Config gets a git configuration.
+func Config(key string, opts ...ConfigOptions) (string, error) {
+	var opt ConfigOptions
+	if len(opts) > 0 {
+		opt = opts[0]
+	}
+	cmd := NewCommand("config")
+	if opt.File != "" {
+		cmd.AddArgs("--file", opt.File)
+	}
+	if opt.All {
+		cmd.AddArgs("--get-all")
+	}
+	for _, a := range opt.Args {
+		cmd.AddArgs(a)
+	}
+	cmd.AddArgs(key)
+	bts, err := cmd.Run()
+	if err != nil {
+		return "", err
+	}
+	return string(bts), nil
+}
+
+// SetConfig sets a git configuration.
+func SetConfig(key string, value string, opts ...ConfigOptions) error {
+	var opt ConfigOptions
+	if len(opts) > 0 {
+		opt = opts[0]
+	}
+	cmd := NewCommand("config")
+	if opt.File != "" {
+		cmd.AddArgs("--file", opt.File)
+	}
+	for _, a := range opt.Args {
+		cmd.AddArgs(a)
+	}
+	cmd.AddArgs(key, value)
+	_, err := cmd.Run()
+	return err
+}

git/errors.go πŸ”—

@@ -11,8 +11,8 @@ var (
 	ErrFileNotFound = errors.New("file not found")
 	// ErrDirectoryNotFound is returned when a directory is not found.
 	ErrDirectoryNotFound = errors.New("directory not found")
-	// ErrReferenceNotFound is returned when a reference is not found.
-	ErrReferenceNotFound = errors.New("reference not found")
+	// ErrReferenceNotExist is returned when a reference does not exist.
+	ErrReferenceNotExist = git.ErrReferenceNotExist
 	// ErrRevisionNotExist is returned when a revision is not found.
 	ErrRevisionNotExist = git.ErrRevisionNotExist
 	// ErrNotAGitRepository is returned when the given path is not a Git repository.

git/repo.go πŸ”—

@@ -79,7 +79,7 @@ func (r *Repository) Name() string {
 
 // HEAD returns the HEAD reference for a repository.
 func (r *Repository) HEAD() (*Reference, error) {
-	rn, err := r.SymbolicRef()
+	rn, err := r.Repository.SymbolicRef(git.SymbolicRefOptions{Name: "HEAD"})
 	if err != nil {
 		return nil, err
 	}
@@ -212,3 +212,41 @@ func (r *Repository) UpdateServerInfo() error {
 	_, err := cmd.RunInDir(r.Path)
 	return err
 }
+
+// Config returns the config value for the given key.
+func (r *Repository) Config(key string, opts ...ConfigOptions) (string, error) {
+	dir, err := gitDir(r.Repository)
+	if err != nil {
+		return "", err
+	}
+	var opt ConfigOptions
+	if len(opts) > 0 {
+		opt = opts[0]
+	}
+	opt.File = filepath.Join(dir, "config")
+	return Config(key, opt)
+}
+
+// SetConfig sets the config value for the given key.
+func (r *Repository) SetConfig(key, value string, opts ...ConfigOptions) error {
+	dir, err := gitDir(r.Repository)
+	if err != nil {
+		return err
+	}
+	var opt ConfigOptions
+	if len(opts) > 0 {
+		opt = opts[0]
+	}
+	opt.File = filepath.Join(dir, "config")
+	return SetConfig(key, value, opt)
+}
+
+// SymbolicRef returns or updates the symbolic reference for the given name.
+// Both name and ref can be empty.
+func (r *Repository) SymbolicRef(name string, ref string) (string, error) {
+	opt := git.SymbolicRefOptions{
+		Name: name,
+		Ref:  ref,
+	}
+	return r.Repository.SymbolicRef(opt)
+}

git/types.go πŸ”—

@@ -0,0 +1,9 @@
+package git
+
+import "github.com/gogs/git-module"
+
+// CommandOptions contain options for running a git command.
+type CommandOptions = git.CommandOptions
+
+// CloneOptions contain options for cloning a repository.
+type CloneOptions = git.CloneOptions

go.mod πŸ”—

@@ -9,10 +9,8 @@ require (
 	github.com/charmbracelet/bubbletea v0.23.2
 	github.com/charmbracelet/glamour v0.6.0
 	github.com/charmbracelet/lipgloss v0.7.1
-	github.com/charmbracelet/wish v0.7.0
+	github.com/charmbracelet/wish v1.1.0
 	github.com/dustin/go-humanize v1.0.1
-	github.com/gliderlabs/ssh v0.3.5
-	github.com/go-git/go-billy/v5 v5.4.1
 	github.com/go-git/go-git/v5 v5.6.1
 	github.com/matryer/is v1.4.1
 	github.com/muesli/reflow v0.3.0
@@ -22,39 +20,30 @@ require (
 
 require (
 	github.com/aymanbagabas/go-osc52 v1.2.2
-	github.com/charmbracelet/keygen v0.3.0
 	github.com/charmbracelet/log v0.2.1
+	github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103
 	github.com/gobwas/glob v0.2.3
 	github.com/gogs/git-module v1.8.1
-	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da
 	github.com/lrstanley/bubblezone v0.0.0-20220716194435-3cb8c52f6a8f
 	github.com/muesli/mango-cobra v1.2.0
 	github.com/muesli/roff v0.1.0
 	github.com/spf13/cobra v1.6.1
 	golang.org/x/crypto v0.7.0
-	gopkg.in/yaml.v3 v3.0.1
+	golang.org/x/sync v0.1.0
 )
 
 require (
-	github.com/Microsoft/go-winio v0.5.2 // indirect
-	github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect
-	github.com/acomagu/bufpipe v1.0.4 // indirect
 	github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
 	github.com/atotto/clipboard v0.1.4 // indirect
 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
 	github.com/aymerick/douceur v0.2.0 // indirect
 	github.com/caarlos0/sshmarshal v0.1.0 // indirect
-	github.com/cloudflare/circl v1.1.0 // indirect
+	github.com/charmbracelet/keygen v0.3.0 // indirect
 	github.com/containerd/console v1.0.3 // indirect
 	github.com/dlclark/regexp2 v1.4.0 // indirect
-	github.com/emirpasic/gods v1.18.1 // indirect
-	github.com/go-git/gcfg v1.5.0 // indirect
 	github.com/go-logfmt/logfmt v0.6.0 // indirect
 	github.com/gorilla/css v1.0.0 // indirect
-	github.com/imdario/mergo v0.3.13 // indirect
 	github.com/inconshreveable/mousetrap v1.0.1 // indirect
-	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
-	github.com/kevinburke/ssh_config v1.2.0 // indirect
 	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
 	github.com/mattn/go-isatty v0.0.18 // indirect
 	github.com/mattn/go-localereader v0.0.1 // indirect
@@ -67,18 +56,13 @@ require (
 	github.com/muesli/mango v0.1.0 // indirect
 	github.com/muesli/mango-pflag v0.1.0 // indirect
 	github.com/olekukonko/tablewriter v0.0.5 // indirect
-	github.com/pjbgf/sha1cd v0.3.0 // indirect
 	github.com/rivo/uniseg v0.2.0 // indirect
 	github.com/sahilm/fuzzy v0.1.0 // indirect
-	github.com/skeema/knownhosts v1.1.0 // indirect
 	github.com/spf13/pflag v1.0.5 // indirect
-	github.com/xanzy/ssh-agent v0.3.3 // indirect
 	github.com/yuin/goldmark v1.5.2 // indirect
 	github.com/yuin/goldmark-emoji v1.0.1 // indirect
 	golang.org/x/net v0.8.0 // indirect
-	golang.org/x/sync v0.1.0 // indirect
 	golang.org/x/sys v0.6.0 // indirect
 	golang.org/x/term v0.6.0 // indirect
 	golang.org/x/text v0.8.0 // indirect
-	gopkg.in/warnings.v0 v0.1.2 // indirect
 )

go.sum πŸ”—

@@ -1,19 +1,10 @@
-github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
-github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0=
-github.com/Microsoft/go-winio v0.5.2 h1:a9IhgEQBCUEk6QCdml9CiJGhAws+YwffDHEMp1VMrpA=
 github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
-github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo=
-github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA=
 github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g=
-github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
-github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ=
 github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
 github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
 github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
-github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
-github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
@@ -46,11 +37,13 @@ github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0
 github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk=
 github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E=
 github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c=
+github.com/charmbracelet/log v0.1.2/go.mod h1:86XdIdmrubqtL/6u0z+jGFol1bQejBGG/qPSTwGZuQQ=
 github.com/charmbracelet/log v0.2.1 h1:1z7jpkk4yKyjwlmKmKMM5qnEDSpV32E7XtWhuv0mTZE=
 github.com/charmbracelet/log v0.2.1/go.mod h1:GwFfjewhcVDWLrpAbY5A0Hin9YOlEn40eWT4PNaxFT4=
-github.com/charmbracelet/wish v0.7.0 h1:rdfacCWaKCQpCMPbOKfi68GYqsb+9CnUzN1Ov/INZJ0=
-github.com/charmbracelet/wish v0.7.0/go.mod h1:16EQz7k3hEgPkPENghcpEddvlrmucIudE0jnczKr+k4=
-github.com/cloudflare/circl v1.1.0 h1:bZgT/A+cikZnKIwn7xL2OBj012Bmvho/o6RpRvv3GKY=
+github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103 h1:wpHMERIN0pQZE635jWwT1dISgfjbpUcEma+fbPKSMCU=
+github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103/go.mod h1:0Vm2/8yBljiLDnGJHU8ehswfawrEybGk33j5ssqKQVM=
+github.com/charmbracelet/wish v1.1.0 h1:0ArX9SOG70saqd23NYjoS56oLPVNgqcQegkz1Lw+4zY=
+github.com/charmbracelet/wish v1.1.0/go.mod h1:yHbm0hs/qX4lFE7nrhAcXjFYc8bxMIfSqJOfOYfwyYo=
 github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I=
 github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
 github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
@@ -63,23 +56,12 @@ github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E
 github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
-github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
-github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
 github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
-github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
-github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
-github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY=
 github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4=
-github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4=
 github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E=
-github.com/go-git/go-billy/v5 v5.2.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
 github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
-github.com/go-git/go-billy/v5 v5.4.1 h1:Uwp5tDRkPr+l/TnbHOQzp+tmJfLceOlbVucgpTz8ix4=
 github.com/go-git/go-billy/v5 v5.4.1/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg=
-github.com/go-git/go-git-fixtures/v4 v4.2.1/go.mod h1:K8zd3kDUAykwTdDCr+I0per6Y6vMiRR/nnVTBtavnB0=
-github.com/go-git/go-git-fixtures/v4 v4.3.1 h1:y5z6dd3qi8Hl+stezc8p3JxDkoTRqMAlKnXHuzrfjTQ=
 github.com/go-git/go-git-fixtures/v4 v4.3.1/go.mod h1:8LHG1a3SRW71ettAD/jW13h8c6AqjVSeL11RAdgaqpo=
-github.com/go-git/go-git/v5 v5.4.2/go.mod h1:gQ1kArt6d+n+BGd+/B/I74HwRTLhth2+zti4ihgckDc=
 github.com/go-git/go-git/v5 v5.6.1 h1:q4ZRqQl4pR/ZJHc1L5CFjGA1a10u76aV1iC+nh+bHsk=
 github.com/go-git/go-git/v5 v5.6.1/go.mod h1:mvyoL6Unz0PiTQrGQfSfiLFhBH1c1e84ylC2MDs4ee8=
 github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
@@ -88,28 +70,17 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
 github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
 github.com/gogs/git-module v1.8.1 h1:yC5BZ3unJOXC8N6/FgGQ8EtJXpOd217lgDcd2aPOxkc=
 github.com/gogs/git-module v1.8.1/go.mod h1:Y3rsSqtFZEbn7lp+3gWf42GKIY1eNTtLt7JrmOy0yAQ=
-github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
-github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
 github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
-github.com/hashicorp/golang-lru/v2 v2.0.1/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
-github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
-github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk=
+github.com/hashicorp/golang-lru/v2 v2.0.2/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
 github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg=
 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
 github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
 github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
-github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
 github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
-github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
-github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
 github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
-github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
 github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
@@ -124,7 +95,6 @@ github.com/lrstanley/bubblezone v0.0.0-20220716194435-3cb8c52f6a8f/go.mod h1:Cxa
 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
 github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA=
-github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
 github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
 github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
@@ -173,10 +143,7 @@ github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4AN
 github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
 github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
 github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
-github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
 github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
-github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -188,9 +155,7 @@ github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
 github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
 github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
 github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
-github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
 github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
-github.com/skeema/knownhosts v1.1.0 h1:Wvr9V0MxhjRbl3f9nMnKnFfiWTJmtECJ9Njkea3ysW0=
 github.com/skeema/knownhosts v1.1.0/go.mod h1:sKFq3RD6/TKZkSWn8boUbDC7Qkgcv+8XXijpFO6roag=
 github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM=
 github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA=
@@ -198,7 +163,6 @@ github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUq
 github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
@@ -209,8 +173,6 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
 github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
 github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0=
-github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
 github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
@@ -219,10 +181,7 @@ github.com/yuin/goldmark v1.5.2/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5ta
 github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os=
 github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ=
 golang.org/x/arch v0.1.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
-golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
-golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.0.0-20220307211146-efcb8507fb70/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
@@ -237,7 +196,6 @@ golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k=
 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
@@ -249,22 +207,16 @@ golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
 golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -297,24 +249,21 @@ golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
 golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
-golang.org/x/time v0.0.0-20220411224347-583f2d630306/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
 golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA=
 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
-gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
 gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

server/backend/file/file.go πŸ”—

@@ -32,7 +32,7 @@ import (
 	"github.com/charmbracelet/log"
 	"github.com/charmbracelet/soft-serve/git"
 	"github.com/charmbracelet/soft-serve/server/backend"
-	"github.com/gliderlabs/ssh"
+	"github.com/charmbracelet/ssh"
 	gitm "github.com/gogs/git-module"
 	gossh "golang.org/x/crypto/ssh"
 )
@@ -53,6 +53,14 @@ const (
 
 var (
 	logger = log.WithPrefix("backend.file")
+
+	defaults = map[string]string{
+		serverName:   "Soft Serve",
+		serverHost:   "localhost",
+		serverPort:   "23231",
+		anonAccess:   backend.ReadOnlyAccess.String(),
+		allowKeyless: "true",
+	}
 )
 
 var _ backend.Backend = &FileBackend{}
@@ -114,8 +122,17 @@ func NewFileBackend(path string) (*FileBackend, error) {
 		}
 	}
 	for _, file := range []string{admins, anonAccess, allowKeyless, serverHost, serverName, serverPort} {
-		if _, err := os.OpenFile(filepath.Join(path, file), os.O_RDONLY|os.O_CREATE, 0644); err != nil {
-			return nil, err
+		fp := filepath.Join(path, file)
+		_, err := os.Stat(fp)
+		if errors.Is(err, fs.ErrNotExist) {
+			f, err := os.Create(fp)
+			if err != nil {
+				return nil, err
+			}
+			if c, ok := defaults[file]; ok {
+				io.WriteString(f, c) // nolint:errcheck
+			}
+			_ = f.Close()
 		}
 	}
 	return fb, nil
@@ -586,10 +603,7 @@ func (fb *FileBackend) SetDefaultBranch(repo string, branch string) error {
 		return err
 	}
 
-	if _, err := r.SymbolicRef(gitm.SymbolicRefOptions{
-		Name: "HEAD",
-		Ref:  gitm.RefsHeads + branch,
-	}); err != nil {
+	if _, err := r.SymbolicRef("HEAD", gitm.RefsHeads+branch); err != nil {
 		logger.Debug("failed to set default branch", "err", err)
 		return err
 	}

server/backend/noop/noop.go πŸ”—

@@ -0,0 +1,163 @@
+package noop
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+
+	"github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/server/backend"
+	"golang.org/x/crypto/ssh"
+)
+
+var ErrNotImpl = fmt.Errorf("not implemented")
+
+var _ backend.Backend = (*Noop)(nil)
+
+var _ backend.AccessMethod = (*Noop)(nil)
+
+// Noop is a backend that does nothing. It's used for testing.
+type Noop struct {
+	Port string
+}
+
+// AccessLevel implements backend.AccessMethod
+func (*Noop) AccessLevel(repo string, pk ssh.PublicKey) backend.AccessLevel {
+	return backend.AdminAccess
+}
+
+// AddAdmin implements backend.Backend
+func (*Noop) AddAdmin(pk ssh.PublicKey) error {
+	return ErrNotImpl
+}
+
+// AddCollaborator implements backend.Backend
+func (*Noop) AddCollaborator(pk ssh.PublicKey, repo string) error {
+	return ErrNotImpl
+}
+
+// AllowKeyless implements backend.Backend
+func (*Noop) AllowKeyless() bool {
+	return true
+}
+
+// AnonAccess implements backend.Backend
+func (*Noop) AnonAccess() backend.AccessLevel {
+	return backend.AdminAccess
+}
+
+// CreateRepository implements backend.Backend
+func (*Noop) CreateRepository(name string, private bool) (backend.Repository, error) {
+	temp, err := os.MkdirTemp("", "soft-serve")
+	if err != nil {
+		return nil, err
+	}
+
+	rp := filepath.Join(temp, name)
+	_, err = git.Init(rp, private)
+	if err != nil {
+		return nil, err
+	}
+
+	return &repo{path: rp}, nil
+}
+
+// DefaultBranch implements backend.Backend
+func (*Noop) DefaultBranch(repo string) (string, error) {
+	return "", ErrNotImpl
+}
+
+// DeleteRepository implements backend.Backend
+func (*Noop) DeleteRepository(name string) error {
+	return ErrNotImpl
+}
+
+// Description implements backend.Backend
+func (*Noop) Description(repo string) string {
+	return ""
+}
+
+// IsAdmin implements backend.Backend
+func (*Noop) IsAdmin(pk ssh.PublicKey) bool {
+	return true
+}
+
+// IsCollaborator implements backend.Backend
+func (*Noop) IsCollaborator(pk ssh.PublicKey, repo string) bool {
+	return true
+}
+
+// IsPrivate implements backend.Backend
+func (*Noop) IsPrivate(repo string) bool {
+	return false
+}
+
+// RenameRepository implements backend.Backend
+func (*Noop) RenameRepository(oldName string, newName string) error {
+	return ErrNotImpl
+}
+
+// Repositories implements backend.Backend
+func (*Noop) Repositories() ([]backend.Repository, error) {
+	return nil, ErrNotImpl
+}
+
+// Repository implements backend.Backend
+func (*Noop) Repository(repo string) (backend.Repository, error) {
+	return nil, ErrNotImpl
+}
+
+// ServerHost implements backend.Backend
+func (*Noop) ServerHost() string {
+	return "localhost"
+}
+
+// ServerName implements backend.Backend
+func (*Noop) ServerName() string {
+	return "Soft Serve"
+}
+
+// ServerPort implements backend.Backend
+func (n *Noop) ServerPort() string {
+	return n.Port
+}
+
+// SetAllowKeyless implements backend.Backend
+func (*Noop) SetAllowKeyless(allow bool) error {
+	return ErrNotImpl
+}
+
+// SetAnonAccess implements backend.Backend
+func (*Noop) SetAnonAccess(level backend.AccessLevel) error {
+	return ErrNotImpl
+}
+
+// SetDefaultBranch implements backend.Backend
+func (*Noop) SetDefaultBranch(repo string, branch string) error {
+	return ErrNotImpl
+}
+
+// SetDescription implements backend.Backend
+func (*Noop) SetDescription(repo string, desc string) error {
+	return ErrNotImpl
+}
+
+// SetPrivate implements backend.Backend
+func (*Noop) SetPrivate(repo string, priv bool) error {
+	return ErrNotImpl
+}
+
+// SetServerHost implements backend.Backend
+func (*Noop) SetServerHost(host string) error {
+	return ErrNotImpl
+}
+
+// SetServerName implements backend.Backend
+func (*Noop) SetServerName(name string) error {
+	return ErrNotImpl
+}
+
+// SetServerPort implements backend.Backend
+func (*Noop) SetServerPort(port string) error {
+	return ErrNotImpl
+}

server/backend/noop/repo.go πŸ”—

@@ -0,0 +1,32 @@
+package noop
+
+import (
+	"github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/server/backend"
+)
+
+var _ backend.Repository = (*repo)(nil)
+
+type repo struct {
+	path string
+}
+
+// Description implements backend.Repository
+func (*repo) Description() string {
+	return ""
+}
+
+// IsPrivate implements backend.Repository
+func (*repo) IsPrivate() bool {
+	return false
+}
+
+// Name implements backend.Repository
+func (*repo) Name() string {
+	return ""
+}
+
+// Repository implements backend.Repository
+func (r *repo) Repository() (*git.Repository, error) {
+	return git.Open(r.path)
+}

server/cmd/cat.go πŸ”—

@@ -1,123 +0,0 @@
-package cmd
-
-import (
-	"fmt"
-	"strings"
-
-	"github.com/alecthomas/chroma/lexers"
-	gansi "github.com/charmbracelet/glamour/ansi"
-	"github.com/charmbracelet/lipgloss"
-	"github.com/charmbracelet/soft-serve/config"
-	"github.com/charmbracelet/soft-serve/ui/common"
-	gitwish "github.com/charmbracelet/wish/git"
-	"github.com/muesli/termenv"
-	"github.com/spf13/cobra"
-)
-
-var (
-	lineDigitStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("239"))
-	lineBarStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("236"))
-	dirnameStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("#00AAFF"))
-	filenameStyle  = lipgloss.NewStyle()
-	filemodeStyle  = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777"))
-)
-
-// CatCommand returns a command that prints the contents of a file.
-func CatCommand() *cobra.Command {
-	var linenumber bool
-	var color bool
-
-	catCmd := &cobra.Command{
-		Use:   "cat PATH",
-		Short: "Outputs the contents of the file at path.",
-		Args:  cobra.ExactArgs(1),
-		RunE: func(cmd *cobra.Command, args []string) error {
-			ac, s := fromContext(cmd)
-			ps := strings.Split(args[0], "/")
-			rn := ps[0]
-			fp := strings.Join(ps[1:], "/")
-			auth := ac.AuthRepo(rn, s.PublicKey())
-			if auth < gitwish.ReadOnlyAccess {
-				return ErrUnauthorized
-			}
-			var repo *config.Repo
-			repoExists := false
-			for _, rp := range ac.Source.AllRepos() {
-				if rp.Repo() == rn {
-					repoExists = true
-					repo = rp
-					break
-				}
-			}
-			if !repoExists {
-				return ErrRepoNotFound
-			}
-			c, _, err := repo.LatestFile(fp)
-			if err != nil {
-				return err
-			}
-			if color {
-				c, err = withFormatting(fp, c)
-				if err != nil {
-					return err
-				}
-			}
-			if linenumber {
-				c = withLineNumber(c, color)
-			}
-			fmt.Fprint(s, c)
-			return nil
-		},
-	}
-	catCmd.Flags().BoolVarP(&linenumber, "linenumber", "l", false, "Print line numbers")
-	catCmd.Flags().BoolVarP(&color, "color", "c", false, "Colorize output")
-
-	return catCmd
-}
-
-func withLineNumber(s string, color bool) string {
-	lines := strings.Split(s, "\n")
-	// NB: len() is not a particularly safe way to count string width (because
-	// it's counting bytes instead of runes) but in this case it's okay
-	// because we're only dealing with digits, which are one byte each.
-	mll := len(fmt.Sprintf("%d", len(lines)))
-	for i, l := range lines {
-		digit := fmt.Sprintf("%*d", mll, i+1)
-		bar := "β”‚"
-		if color {
-			digit = lineDigitStyle.Render(digit)
-			bar = lineBarStyle.Render(bar)
-		}
-		if i < len(lines)-1 || len(l) != 0 {
-			// If the final line was a newline we'll get an empty string for
-			// the final line, so drop the newline altogether.
-			lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l)
-		}
-	}
-	return strings.Join(lines, "\n")
-}
-
-func withFormatting(p, c string) (string, error) {
-	zero := uint(0)
-	lang := ""
-	lexer := lexers.Match(p)
-	if lexer != nil && lexer.Config() != nil {
-		lang = lexer.Config().Name
-	}
-	formatter := &gansi.CodeBlockElement{
-		Code:     c,
-		Language: lang,
-	}
-	r := strings.Builder{}
-	styles := common.StyleConfig()
-	styles.CodeBlock.Margin = &zero
-	rctx := gansi.NewRenderContext(gansi.Options{
-		Styles:       styles,
-		ColorProfile: termenv.TrueColor,
-	})
-	err := formatter.Render(&r, rctx)
-	if err != nil {
-		return "", err
-	}
-	return r.String(), nil
-}

server/cmd/cmd.go πŸ”—

@@ -3,8 +3,8 @@ package cmd
 import (
 	"fmt"
 
-	appCfg "github.com/charmbracelet/soft-serve/config"
-	"github.com/gliderlabs/ssh"
+	"github.com/charmbracelet/soft-serve/server/config"
+	"github.com/charmbracelet/ssh"
 	"github.com/spf13/cobra"
 )
 
@@ -68,18 +68,15 @@ func RootCommand() *cobra.Command {
 	rootCmd.SetUsageTemplate(usageTemplate)
 	rootCmd.CompletionOptions.DisableDefaultCmd = true
 	rootCmd.AddCommand(
-		ReloadCommand(),
-		CatCommand(),
-		ListCommand(),
-		GitCommand(),
+		RepoCommand(),
 	)
 
 	return rootCmd
 }
 
-func fromContext(cmd *cobra.Command) (*appCfg.Config, ssh.Session) {
+func fromContext(cmd *cobra.Command) (*config.Config, ssh.Session) {
 	ctx := cmd.Context()
-	ac := ctx.Value(ConfigCtxKey).(*appCfg.Config)
+	cfg := ctx.Value(ConfigCtxKey).(*config.Config)
 	s := ctx.Value(SessionCtxKey).(ssh.Session)
-	return ac, s
+	return cfg, s
 }

server/cmd/git.go πŸ”—

@@ -1,54 +0,0 @@
-package cmd
-
-import (
-	"io"
-	"os/exec"
-
-	"github.com/charmbracelet/soft-serve/config"
-	gitwish "github.com/charmbracelet/wish/git"
-	"github.com/spf13/cobra"
-)
-
-// GitCommand returns a command that handles Git operations.
-func GitCommand() *cobra.Command {
-	gitCmd := &cobra.Command{
-		Use:   "git REPO COMMAND",
-		Short: "Perform Git operations on a repository.",
-		RunE: func(cmd *cobra.Command, args []string) error {
-			ac, s := fromContext(cmd)
-			auth := ac.AuthRepo("config", s.PublicKey())
-			if auth < gitwish.AdminAccess {
-				return ErrUnauthorized
-			}
-			if len(args) < 1 {
-				return runGit(nil, s, s, "")
-			}
-			var repo *config.Repo
-			rn := args[0]
-			repoExists := false
-			for _, rp := range ac.Source.AllRepos() {
-				if rp.Repo() == rn {
-					repoExists = true
-					repo = rp
-					break
-				}
-			}
-			if !repoExists {
-				return ErrRepoNotFound
-			}
-			return runGit(nil, s, s, repo.Path(), args[1:]...)
-		},
-	}
-	gitCmd.Flags().SetInterspersed(false)
-
-	return gitCmd
-}
-
-func runGit(in io.Reader, out, err io.Writer, dir string, args ...string) error {
-	cmd := exec.Command("git", args...)
-	cmd.Stdin = in
-	cmd.Stdout = out
-	cmd.Stderr = err
-	cmd.Dir = dir
-	return cmd.Run()
-}

server/cmd/list.go πŸ”—

@@ -1,83 +0,0 @@
-package cmd
-
-import (
-	"fmt"
-	"path/filepath"
-	"strings"
-
-	"github.com/charmbracelet/soft-serve/git"
-	gitwish "github.com/charmbracelet/wish/git"
-	"github.com/spf13/cobra"
-)
-
-// ListCommand returns a command that list file or directory at path.
-func ListCommand() *cobra.Command {
-	lsCmd := &cobra.Command{
-		Use:     "ls PATH",
-		Aliases: []string{"list"},
-		Short:   "List file or directory at path.",
-		Args:    cobra.RangeArgs(0, 1),
-		RunE: func(cmd *cobra.Command, args []string) error {
-			ac, s := fromContext(cmd)
-			rn := ""
-			path := ""
-			ps := []string{}
-			if len(args) > 0 {
-				path = filepath.Clean(args[0])
-				ps = strings.Split(path, "/")
-				rn = ps[0]
-				auth := ac.AuthRepo(rn, s.PublicKey())
-				if auth < gitwish.ReadOnlyAccess {
-					return ErrUnauthorized
-				}
-			}
-			if path == "" || path == "." || path == "/" {
-				for _, r := range ac.Source.AllRepos() {
-					if ac.AuthRepo(r.Repo(), s.PublicKey()) >= gitwish.ReadOnlyAccess {
-						fmt.Fprintln(s, r.Repo())
-					}
-				}
-				return nil
-			}
-			r, err := ac.Source.GetRepo(rn)
-			if err != nil {
-				return err
-			}
-			head, err := r.HEAD()
-			if err != nil {
-				return err
-			}
-			tree, err := r.Tree(head, "")
-			if err != nil {
-				return err
-			}
-			subpath := strings.Join(ps[1:], "/")
-			ents := git.Entries{}
-			te, err := tree.TreeEntry(subpath)
-			if err == git.ErrRevisionNotExist {
-				return ErrFileNotFound
-			}
-			if err != nil {
-				return err
-			}
-			if te.Type() == "tree" {
-				tree, err = tree.SubTree(subpath)
-				if err != nil {
-					return err
-				}
-				ents, err = tree.Entries()
-				if err != nil {
-					return err
-				}
-			} else {
-				ents = append(ents, te)
-			}
-			ents.Sort()
-			for _, ent := range ents {
-				fmt.Fprintf(s, "%s\t%d\t %s\n", ent.Mode(), ent.Size(), ent.Name())
-			}
-			return nil
-		},
-	}
-	return lsCmd
-}

server/middleware.go β†’ server/cmd/middleware.go πŸ”—

@@ -1,17 +1,16 @@
-package server
+package cmd
 
 import (
 	"context"
 	"fmt"
 
-	appCfg "github.com/charmbracelet/soft-serve/config"
-	"github.com/charmbracelet/soft-serve/server/cmd"
+	"github.com/charmbracelet/soft-serve/server/config"
+	"github.com/charmbracelet/ssh"
 	"github.com/charmbracelet/wish"
-	"github.com/gliderlabs/ssh"
 )
 
-// softMiddleware is the Soft Serve middleware that handles SSH commands.
-func softMiddleware(ac *appCfg.Config) wish.Middleware {
+// Middleware is the Soft Serve middleware that handles SSH commands.
+func Middleware(cfg *config.Config) wish.Middleware {
 	return func(sh ssh.Handler) ssh.Handler {
 		return func(s ssh.Session) {
 			func() {
@@ -19,16 +18,16 @@ func softMiddleware(ac *appCfg.Config) wish.Middleware {
 				if active {
 					return
 				}
-				ctx := context.WithValue(s.Context(), cmd.ConfigCtxKey, ac)
-				ctx = context.WithValue(ctx, cmd.SessionCtxKey, s)
+				ctx := context.WithValue(s.Context(), ConfigCtxKey, cfg)
+				ctx = context.WithValue(ctx, SessionCtxKey, s)
 
 				use := "ssh"
-				port := ac.Port
-				if port != 22 {
-					use += fmt.Sprintf(" -p%d", port)
+				port := cfg.Backend.ServerPort()
+				if port != "22" {
+					use += fmt.Sprintf(" -p%s", port)
 				}
-				use += fmt.Sprintf(" %s", ac.Host)
-				cmd := cmd.RootCommand()
+				use += fmt.Sprintf(" %s", cfg.Backend.ServerHost())
+				cmd := RootCommand()
 				cmd.Use = use
 				cmd.CompletionOptions.DisableDefaultCmd = true
 				cmd.SetIn(s)

server/cmd/reload.go πŸ”—

@@ -1,23 +0,0 @@
-package cmd
-
-import (
-	gitwish "github.com/charmbracelet/wish/git"
-	"github.com/spf13/cobra"
-)
-
-// ReloadCommand returns a command that reloads the server configuration.
-func ReloadCommand() *cobra.Command {
-	reloadCmd := &cobra.Command{
-		Use:   "reload",
-		Short: "Reloads the configuration",
-		RunE: func(cmd *cobra.Command, args []string) error {
-			ac, s := fromContext(cmd)
-			auth := ac.AuthRepo("config", s.PublicKey())
-			if auth < gitwish.AdminAccess {
-				return ErrUnauthorized
-			}
-			return ac.Reload()
-		},
-	}
-	return reloadCmd
-}

server/cmd/repo.go πŸ”—

@@ -0,0 +1,408 @@
+package cmd
+
+import (
+	"fmt"
+	"path/filepath"
+	"strconv"
+	"strings"
+
+	"github.com/alecthomas/chroma/lexers"
+	gansi "github.com/charmbracelet/glamour/ansi"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/server/backend"
+	"github.com/charmbracelet/soft-serve/ui/common"
+	"github.com/muesli/termenv"
+	"github.com/spf13/cobra"
+)
+
+// RepoCommand is the command for managing repositories.
+func RepoCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:     "repo COMMAND",
+		Aliases: []string{"repository", "repositories"},
+		Short:   "Manage repositories.",
+	}
+	cmd.AddCommand(
+		setCommand(),
+		createCommand(),
+		deleteCommand(),
+		listCommand(),
+		showCommand(),
+	)
+	return cmd
+}
+
+func setCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "set",
+		Short: "Set repository properties.",
+	}
+	cmd.AddCommand(
+		setName(),
+		setDescription(),
+		setPrivate(),
+		setDefaultBranch(),
+	)
+	return cmd
+}
+
+// createCommand is the command for creating a new repository.
+func createCommand() *cobra.Command {
+	var private bool
+	var description string
+	var projectName string
+	cmd := &cobra.Command{
+		Use:   "create REPOSITORY",
+		Short: "Create a new repository.",
+		Args:  cobra.ExactArgs(1),
+		PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
+			cfg, s := fromContext(cmd)
+			if !cfg.Backend.IsAdmin(s.PublicKey()) {
+				return ErrUnauthorized
+			}
+			return nil
+		},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, _ := fromContext(cmd)
+			name := args[0]
+			if _, err := cfg.Backend.CreateRepository(name, private); err != nil {
+				return err
+			}
+			return nil
+		},
+	}
+	cmd.Flags().BoolVarP(&private, "private", "p", false, "make the repository private")
+	cmd.Flags().StringVarP(&description, "description", "d", "", "set the repository description")
+	cmd.Flags().StringVarP(&projectName, "project-name", "n", "", "set the project name")
+	return cmd
+}
+
+func deleteCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:               "delete REPOSITORY",
+		Short:             "Delete a repository.",
+		Args:              cobra.ExactArgs(1),
+		PersistentPreRunE: checkIfAdmin,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, _ := fromContext(cmd)
+			name := args[0]
+			if err := cfg.Backend.DeleteRepository(name); err != nil {
+				return err
+			}
+			return nil
+		},
+	}
+	return cmd
+}
+
+func checkIfReadable(cmd *cobra.Command, args []string) error {
+	var repo string
+	if len(args) > 0 {
+		repo = args[0]
+	}
+	cfg, s := fromContext(cmd)
+	rn := strings.TrimSuffix(repo, ".git")
+	auth := cfg.Access.AccessLevel(rn, s.PublicKey())
+	if auth < backend.ReadOnlyAccess {
+		return ErrUnauthorized
+	}
+	return nil
+}
+
+func checkIfAdmin(cmd *cobra.Command, args []string) error {
+	cfg, s := fromContext(cmd)
+	if !cfg.Backend.IsAdmin(s.PublicKey()) {
+		return ErrUnauthorized
+	}
+	return nil
+}
+
+func checkIfCollab(cmd *cobra.Command, args []string) error {
+	var repo string
+	if len(args) > 0 {
+		repo = args[0]
+	}
+	cfg, s := fromContext(cmd)
+	rn := strings.TrimSuffix(repo, ".git")
+	auth := cfg.Access.AccessLevel(rn, s.PublicKey())
+	if auth < backend.ReadWriteAccess {
+		return ErrUnauthorized
+	}
+	return nil
+}
+
+func setName() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:               "name REPOSITORY NEW_NAME",
+		Short:             "Set the name for a repository.",
+		Args:              cobra.ExactArgs(2),
+		PersistentPreRunE: checkIfAdmin,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, _ := fromContext(cmd)
+			oldName := args[0]
+			newName := args[1]
+			if err := cfg.Backend.RenameRepository(oldName, newName); err != nil {
+				return err
+			}
+			return nil
+		},
+	}
+	return cmd
+}
+
+func setDescription() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:               "description REPOSITORY DESCRIPTION",
+		Short:             "Set the description for a repository.",
+		Args:              cobra.MinimumNArgs(2),
+		PersistentPreRunE: checkIfCollab,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, _ := fromContext(cmd)
+			rn := strings.TrimSuffix(args[0], ".git")
+			if err := cfg.Backend.SetDescription(rn, strings.Join(args[1:], " ")); err != nil {
+				return err
+			}
+			return nil
+		},
+	}
+	return cmd
+}
+
+func setPrivate() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:               "private REPOSITORY [true|false]",
+		Short:             "Set a repository to private.",
+		Args:              cobra.ExactArgs(2),
+		PersistentPreRunE: checkIfCollab,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, _ := fromContext(cmd)
+			rn := strings.TrimSuffix(args[0], ".git")
+			isPrivate, err := strconv.ParseBool(args[1])
+			if err != nil {
+				return err
+			}
+			if err := cfg.Backend.SetPrivate(rn, isPrivate); err != nil {
+				return err
+			}
+			return nil
+		},
+	}
+	return cmd
+}
+
+func setDefaultBranch() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:               "default-branch REPOSITORY BRANCH",
+		Short:             "Set the default branch for a repository.",
+		Args:              cobra.ExactArgs(2),
+		PersistentPreRunE: checkIfAdmin,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, _ := fromContext(cmd)
+			rn := strings.TrimSuffix(args[0], ".git")
+			if err := cfg.Backend.SetDefaultBranch(rn, args[1]); err != nil {
+				return err
+			}
+			return nil
+		},
+	}
+	return cmd
+}
+
+// listCommand returns a command that list file or directory at path.
+func listCommand() *cobra.Command {
+	listCmd := &cobra.Command{
+		Use:               "list PATH",
+		Aliases:           []string{"ls"},
+		Short:             "List file or directory at path.",
+		Args:              cobra.RangeArgs(0, 1),
+		PersistentPreRunE: checkIfReadable,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, s := fromContext(cmd)
+			rn := ""
+			path := ""
+			ps := []string{}
+			if len(args) > 0 {
+				path = filepath.Clean(args[0])
+				ps = strings.Split(path, "/")
+				rn = strings.TrimSuffix(ps[0], ".git")
+				auth := cfg.Access.AccessLevel(rn, s.PublicKey())
+				if auth < backend.ReadOnlyAccess {
+					return ErrUnauthorized
+				}
+			}
+			if path == "" || path == "." || path == "/" {
+				repos, err := cfg.Backend.Repositories()
+				if err != nil {
+					return err
+				}
+				for _, r := range repos {
+					if cfg.Access.AccessLevel(r.Name(), s.PublicKey()) >= backend.ReadOnlyAccess {
+						fmt.Fprintln(s, r.Name())
+					}
+				}
+				return nil
+			}
+			rr, err := cfg.Backend.Repository(rn)
+			if err != nil {
+				return err
+			}
+			r, err := rr.Repository()
+			if err != nil {
+				return err
+			}
+			head, err := r.HEAD()
+			if err != nil {
+				if bs, err := r.Branches(); err != nil && len(bs) == 0 {
+					return fmt.Errorf("repository is empty")
+				}
+				return err
+			}
+			tree, err := r.TreePath(head, "")
+			if err != nil {
+				return err
+			}
+			subpath := strings.Join(ps[1:], "/")
+			ents := git.Entries{}
+			te, err := tree.TreeEntry(subpath)
+			if err == git.ErrRevisionNotExist {
+				return ErrFileNotFound
+			}
+			if err != nil {
+				return err
+			}
+			if te.Type() == "tree" {
+				tree, err = tree.SubTree(subpath)
+				if err != nil {
+					return err
+				}
+				ents, err = tree.Entries()
+				if err != nil {
+					return err
+				}
+			} else {
+				ents = append(ents, te)
+			}
+			ents.Sort()
+			for _, ent := range ents {
+				fmt.Fprintf(s, "%s\t%d\t %s\n", ent.Mode(), ent.Size(), ent.Name())
+			}
+			return nil
+		},
+	}
+	return listCmd
+}
+
+var (
+	lineDigitStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("239"))
+	lineBarStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("236"))
+	dirnameStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("#00AAFF"))
+	filenameStyle  = lipgloss.NewStyle()
+	filemodeStyle  = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777"))
+)
+
+// showCommand returns a command that prints the contents of a file.
+func showCommand() *cobra.Command {
+	var linenumber bool
+	var color bool
+
+	showCmd := &cobra.Command{
+		Use:               "show PATH",
+		Aliases:           []string{"cat"},
+		Short:             "Outputs the contents of the file at path.",
+		Args:              cobra.ExactArgs(1),
+		PersistentPreRunE: checkIfReadable,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, s := fromContext(cmd)
+			ps := strings.Split(args[0], "/")
+			rn := strings.TrimSuffix(ps[0], ".git")
+			fp := strings.Join(ps[1:], "/")
+			auth := cfg.Access.AccessLevel(rn, s.PublicKey())
+			if auth < backend.ReadOnlyAccess {
+				return ErrUnauthorized
+			}
+			var repo backend.Repository
+			repoExists := false
+			repos, err := cfg.Backend.Repositories()
+			if err != nil {
+				return err
+			}
+			for _, rp := range repos {
+				if rp.Name() == rn {
+					repoExists = true
+					repo = rp
+					break
+				}
+			}
+			if !repoExists {
+				return ErrRepoNotFound
+			}
+			c, _, err := backend.LatestFile(repo, fp)
+			if err != nil {
+				return err
+			}
+			if color {
+				c, err = withFormatting(fp, c)
+				if err != nil {
+					return err
+				}
+			}
+			if linenumber {
+				c = withLineNumber(c, color)
+			}
+			fmt.Fprint(s, c)
+			return nil
+		},
+	}
+	showCmd.Flags().BoolVarP(&linenumber, "linenumber", "l", false, "Print line numbers")
+	showCmd.Flags().BoolVarP(&color, "color", "c", false, "Colorize output")
+
+	return showCmd
+}
+
+func withLineNumber(s string, color bool) string {
+	lines := strings.Split(s, "\n")
+	// NB: len() is not a particularly safe way to count string width (because
+	// it's counting bytes instead of runes) but in this case it's okay
+	// because we're only dealing with digits, which are one byte each.
+	mll := len(fmt.Sprintf("%d", len(lines)))
+	for i, l := range lines {
+		digit := fmt.Sprintf("%*d", mll, i+1)
+		bar := "β”‚"
+		if color {
+			digit = lineDigitStyle.Render(digit)
+			bar = lineBarStyle.Render(bar)
+		}
+		if i < len(lines)-1 || len(l) != 0 {
+			// If the final line was a newline we'll get an empty string for
+			// the final line, so drop the newline altogether.
+			lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l)
+		}
+	}
+	return strings.Join(lines, "\n")
+}
+
+func withFormatting(p, c string) (string, error) {
+	zero := uint(0)
+	lang := ""
+	lexer := lexers.Match(p)
+	if lexer != nil && lexer.Config() != nil {
+		lang = lexer.Config().Name
+	}
+	formatter := &gansi.CodeBlockElement{
+		Code:     c,
+		Language: lang,
+	}
+	r := strings.Builder{}
+	styles := common.StyleConfig()
+	styles.CodeBlock.Margin = &zero
+	rctx := gansi.NewRenderContext(gansi.Options{
+		Styles:       styles,
+		ColorProfile: termenv.TrueColor,
+	})
+	err := formatter.Render(&r, rctx)
+	if err != nil {
+		return "", err
+	}
+	return r.String(), nil
+}

server/config/config.go πŸ”—

@@ -1,58 +1,93 @@
 package config
 
 import (
-	glog "log"
-	"path/filepath"
-
 	"github.com/caarlos0/env/v6"
 	"github.com/charmbracelet/log"
+	"github.com/charmbracelet/soft-serve/server/backend"
+	"github.com/charmbracelet/soft-serve/server/backend/file"
 )
 
-// Callbacks provides an interface that can be used to run callbacks on different events.
-type Callbacks interface {
-	Tui(action string)
-	Push(repo string)
-	Fetch(repo string)
+// SSHConfig is the configuration for the SSH server.
+type SSHConfig struct {
+	// ListenAddr is the address on which the SSH server will listen.
+	ListenAddr string `env:"LISTEN_ADDR" envDefault:":23231"`
+
+	// KeyPath is the path to the SSH server's private key.
+	KeyPath string `env:"KEY_PATH" envDefault:"soft_serve"`
+
+	// MaxTimeout is the maximum number of seconds a connection can take.
+	MaxTimeout int `env:"MAX_TIMEOUT" envDefault:"0"`
+
+	// IdleTimeout is the number of seconds a connection can be idle before it is closed.
+	IdleTimeout int `env:"IDLE_TIMEOUT" envDefault:"120"`
+}
+
+// GitConfig is the Git daemon configuration for the server.
+type GitConfig struct {
+	// ListenAddr is the address on which the Git daemon will listen.
+	ListenAddr string `env:"LISTEN_ADDR" envDefault:":9418"`
+
+	// MaxTimeout is the maximum number of seconds a connection can take.
+	MaxTimeout int `env:"MAX_TIMEOUT" envDefault:"0"`
+
+	// IdleTimeout is the number of seconds a connection can be idle before it is closed.
+	IdleTimeout int `env:"IDLE_TIMEOUT" envDefault:"3"`
+
+	// MaxConnections is the maximum number of concurrent connections.
+	MaxConnections int `env:"MAX_CONNECTIONS" envDefault:"32"`
 }
 
 // Config is the configuration for Soft Serve.
 type Config struct {
-	BindAddr         string   `env:"SOFT_SERVE_BIND_ADDRESS" envDefault:""`
-	Host             string   `env:"SOFT_SERVE_HOST" envDefault:"localhost"`
-	Port             int      `env:"SOFT_SERVE_PORT" envDefault:"23231"`
-	KeyPath          string   `env:"SOFT_SERVE_KEY_PATH"`
-	RepoPath         string   `env:"SOFT_SERVE_REPO_PATH" envDefault:".repos"`
-	Debug            bool     `env:"SOFT_SERVE_DEBUG" envDefault:"false"`
-	InitialAdminKeys []string `env:"SOFT_SERVE_INITIAL_ADMIN_KEY" envSeparator:"\n"`
-	Callbacks        Callbacks
-	ErrorLog         *glog.Logger
+	// SSH is the configuration for the SSH server.
+	SSH SSHConfig `envPrefix:"SSH_"`
+
+	// Git is the configuration for the Git daemon.
+	Git GitConfig `envPrefix:"GIT_"`
+
+	// InitialAdminKeys is a list of public keys that will be added to the list of admins.
+	InitialAdminKeys []string `env:"INITIAL_ADMIN_KEY" envSeparator:"\n"`
+
+	// DataPath is the path to the directory where Soft Serve will store its data.
+	DataPath string `env:"DATA_PATH" envDefault:"data"`
+
+	// Debug enables debug logging.
+	Debug bool `env:"DEBUG" envDefault:"false"`
+
+	// Backend is the Git backend to use.
+	Backend backend.Backend
+
+	// Access is the access control backend to use.
+	Access backend.AccessMethod
 }
 
 // DefaultConfig returns a Config with the values populated with the defaults
 // or specified environment variables.
 func DefaultConfig() *Config {
-	cfg := &Config{ErrorLog: log.StandardLog(log.StandardLogOptions{ForceLevel: log.ErrorLevel})}
-	if err := env.Parse(cfg); err != nil {
+	cfg := &Config{}
+	if err := env.Parse(cfg, env.Options{
+		Prefix: "SOFT_SERVE_",
+	}); err != nil {
 		log.Fatal(err)
 	}
 	if cfg.Debug {
 		log.SetLevel(log.DebugLevel)
 	}
-	if cfg.KeyPath == "" {
-		// NB: cross-platform-compatible path
-		cfg.KeyPath = filepath.Join(".ssh", "soft_serve_server_ed25519")
+	fb, err := file.NewFileBackend(cfg.DataPath)
+	if err != nil {
+		log.Fatal(err)
 	}
-	return cfg.WithCallbacks(nil)
+	return cfg.WithBackend(fb).WithAccessMethod(fb)
 }
 
-// WithCallbacks applies the given Callbacks to the configuration.
-func (c *Config) WithCallbacks(callbacks Callbacks) *Config {
-	c.Callbacks = callbacks
+// WithBackend sets the backend for the configuration.
+func (c *Config) WithBackend(backend backend.Backend) *Config {
+	c.Backend = backend
 	return c
 }
 
-// WithErrorLogger sets the error logger for the configuration.
-func (c *Config) WithErrorLogger(logger *glog.Logger) *Config {
-	c.ErrorLog = logger
+// WithAccessMethod sets the access control method for the configuration.
+func (c *Config) WithAccessMethod(access backend.AccessMethod) *Config {
+	c.Access = access
 	return c
 }

server/daemon.go πŸ”—

@@ -0,0 +1,299 @@
+package server
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"io"
+	"log"
+	"net"
+	"path/filepath"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/charmbracelet/soft-serve/server/backend"
+	"github.com/charmbracelet/soft-serve/server/config"
+	"github.com/go-git/go-git/v5/plumbing/format/pktline"
+)
+
+// ErrServerClosed indicates that the server has been closed.
+var ErrServerClosed = errors.New("git: Server closed")
+
+// connections synchronizes access to to a net.Conn pool.
+type connections struct {
+	m  map[net.Conn]struct{}
+	mu sync.Mutex
+}
+
+func (m *connections) Add(c net.Conn) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	m.m[c] = struct{}{}
+}
+
+func (m *connections) Close(c net.Conn) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	_ = c.Close()
+	delete(m.m, c)
+}
+
+func (m *connections) Size() int {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	return len(m.m)
+}
+
+func (m *connections) CloseAll() {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	for c := range m.m {
+		_ = c.Close()
+		delete(m.m, c)
+	}
+}
+
+// GitDaemon represents a Git daemon.
+type GitDaemon struct {
+	listener net.Listener
+	addr     string
+	finished chan struct{}
+	conns    connections
+	cfg      *config.Config
+	wg       sync.WaitGroup
+	once     sync.Once
+}
+
+// NewDaemon returns a new Git daemon.
+func NewGitDaemon(cfg *config.Config) (*GitDaemon, error) {
+	addr := cfg.Git.ListenAddr
+	d := &GitDaemon{
+		addr:     addr,
+		finished: make(chan struct{}, 1),
+		cfg:      cfg,
+		conns:    connections{m: make(map[net.Conn]struct{})},
+	}
+	listener, err := net.Listen("tcp", d.addr)
+	if err != nil {
+		return nil, err
+	}
+	d.listener = listener
+	return d, nil
+}
+
+// Start starts the Git TCP daemon.
+func (d *GitDaemon) Start() error {
+	defer d.listener.Close() // nolint: errcheck
+
+	d.wg.Add(1)
+	defer d.wg.Done()
+
+	var tempDelay time.Duration
+	for {
+		conn, err := d.listener.Accept()
+		if err != nil {
+			select {
+			case <-d.finished:
+				return ErrServerClosed
+			default:
+				log.Printf("git: error accepting connection: %v", err)
+			}
+			if ne, ok := err.(net.Error); ok && ne.Temporary() {
+				if tempDelay == 0 {
+					tempDelay = 5 * time.Millisecond
+				} else {
+					tempDelay *= 2
+				}
+				if max := 1 * time.Second; tempDelay > max {
+					tempDelay = max
+				}
+				time.Sleep(tempDelay)
+				continue
+			}
+			return err
+		}
+
+		// Close connection if there are too many open connections.
+		if d.conns.Size()+1 >= d.cfg.Git.MaxConnections {
+			log.Printf("git: max connections reached, closing %s", conn.RemoteAddr())
+			fatal(conn, ErrMaxConnections)
+			continue
+		}
+
+		d.wg.Add(1)
+		go func() {
+			d.handleClient(conn)
+			d.wg.Done()
+		}()
+	}
+}
+
+func fatal(c net.Conn, err error) {
+	WritePktline(c, err)
+	if err := c.Close(); err != nil {
+		log.Printf("git: error closing connection: %v", err)
+	}
+}
+
+// handleClient handles a git protocol client.
+func (d *GitDaemon) handleClient(conn net.Conn) {
+	ctx, cancel := context.WithCancel(context.Background())
+	idleTimeout := time.Duration(d.cfg.Git.IdleTimeout) * time.Second
+	c := &serverConn{
+		Conn:          conn,
+		idleTimeout:   idleTimeout,
+		closeCanceler: cancel,
+	}
+	if d.cfg.Git.MaxTimeout > 0 {
+		dur := time.Duration(d.cfg.Git.MaxTimeout) * time.Second
+		c.maxDeadline = time.Now().Add(dur)
+	}
+	d.conns.Add(c)
+	defer func() {
+		d.conns.Close(c)
+	}()
+
+	readc := make(chan struct{}, 1)
+	s := pktline.NewScanner(c)
+	go func() {
+		if !s.Scan() {
+			if err := s.Err(); err != nil {
+				if nerr, ok := err.(net.Error); ok && nerr.Timeout() {
+					fatal(c, ErrTimeout)
+				} else {
+					log.Printf("git: error scanning pktline: %v", err)
+					fatal(c, ErrSystemMalfunction)
+				}
+			}
+			return
+		}
+		readc <- struct{}{}
+	}()
+
+	select {
+	case <-ctx.Done():
+		if err := ctx.Err(); err != nil {
+			log.Printf("git: connection context error: %v", err)
+		}
+		return
+	case <-readc:
+		line := s.Bytes()
+		split := bytes.SplitN(line, []byte{' '}, 2)
+		if len(split) != 2 {
+			fatal(c, ErrInvalidRequest)
+			return
+		}
+
+		var gitPack func(io.Reader, io.Writer, io.Writer, string) error
+		var repo string
+		cmd := string(split[0])
+		switch cmd {
+		case UploadPackBin:
+			gitPack = UploadPack
+		case UploadArchiveBin:
+			gitPack = UploadArchive
+		default:
+			fatal(c, ErrInvalidRequest)
+			return
+		}
+
+		opts := bytes.Split(split[1], []byte{'\x00'})
+		if len(opts) == 0 {
+			fatal(c, ErrInvalidRequest)
+			return
+		}
+
+		repo = filepath.Clean(string(opts[0]))
+		log.Printf("git: connect %s %s %s", c.RemoteAddr(), cmd, repo)
+		defer log.Printf("git: disconnect %s %s %s", c.RemoteAddr(), cmd, repo)
+		repo = strings.TrimPrefix(repo, "/")
+		auth := d.cfg.Access.AccessLevel(strings.TrimSuffix(repo, ".git"), nil)
+		if auth < backend.ReadOnlyAccess {
+			fatal(c, ErrNotAuthed)
+			return
+		}
+		// git bare repositories should end in ".git"
+		// https://git-scm.com/docs/gitrepository-layout
+		repo = strings.TrimSuffix(repo, ".git") + ".git"
+		// FIXME: determine repositories path
+		repoDir := filepath.Join(d.cfg.DataPath, "repos", repo)
+		if err := gitPack(c, c, c, repoDir); err != nil {
+			fatal(c, err)
+			return
+		}
+	}
+}
+
+// Close closes the underlying listener.
+func (d *GitDaemon) Close() error {
+	d.once.Do(func() { close(d.finished) })
+	err := d.listener.Close()
+	d.conns.CloseAll()
+	return err
+}
+
+// Shutdown gracefully shuts down the daemon.
+func (d *GitDaemon) Shutdown(ctx context.Context) error {
+	d.once.Do(func() { close(d.finished) })
+	err := d.listener.Close()
+	finished := make(chan struct{}, 1)
+	go func() {
+		d.wg.Wait()
+		finished <- struct{}{}
+	}()
+	select {
+	case <-ctx.Done():
+		return ctx.Err()
+	case <-finished:
+		return err
+	}
+}
+
+type serverConn struct {
+	net.Conn
+
+	idleTimeout   time.Duration
+	maxDeadline   time.Time
+	closeCanceler context.CancelFunc
+}
+
+func (c *serverConn) Write(p []byte) (n int, err error) {
+	c.updateDeadline()
+	n, err = c.Conn.Write(p)
+	if _, isNetErr := err.(net.Error); isNetErr && c.closeCanceler != nil {
+		c.closeCanceler()
+	}
+	return
+}
+
+func (c *serverConn) Read(b []byte) (n int, err error) {
+	c.updateDeadline()
+	n, err = c.Conn.Read(b)
+	if _, isNetErr := err.(net.Error); isNetErr && c.closeCanceler != nil {
+		c.closeCanceler()
+	}
+	return
+}
+
+func (c *serverConn) Close() (err error) {
+	err = c.Conn.Close()
+	if c.closeCanceler != nil {
+		c.closeCanceler()
+	}
+	return
+}
+
+func (c *serverConn) updateDeadline() {
+	switch {
+	case c.idleTimeout > 0:
+		idleDeadline := time.Now().Add(c.idleTimeout)
+		if idleDeadline.Unix() < c.maxDeadline.Unix() || c.maxDeadline.IsZero() {
+			c.Conn.SetDeadline(idleDeadline)
+			return
+		}
+		fallthrough
+	default:
+		c.Conn.SetDeadline(c.maxDeadline)
+	}
+}

server/daemon_test.go πŸ”—

@@ -0,0 +1,95 @@
+package server
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"io"
+	"log"
+	"net"
+	"os"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/charmbracelet/soft-serve/server/config"
+	"github.com/go-git/go-git/v5/plumbing/format/pktline"
+)
+
+var testDaemon *GitDaemon
+
+func TestMain(m *testing.M) {
+	tmp, err := os.MkdirTemp("", "soft-serve-test")
+	if err != nil {
+		log.Fatal(err)
+	}
+	defer os.RemoveAll(tmp)
+	os.Setenv("SOFT_SERVE_DATA_PATH", tmp)
+	os.Setenv("SOFT_SERVE_GIT_MAX_CONNECTIONS", "3")
+	os.Setenv("SOFT_SERVE_GIT_MAX_TIMEOUT", "100")
+	os.Setenv("SOFT_SERVE_GIT_IDLE_TIMEOUT", "1")
+	os.Setenv("SOFT_SERVE_GIT_LISTEN_ADDR", fmt.Sprintf(":%d", randomPort()))
+	cfg := config.DefaultConfig()
+	d, err := NewGitDaemon(cfg)
+	if err != nil {
+		log.Fatal(err)
+	}
+	testDaemon = d
+	go func() {
+		if err := d.Start(); err != ErrServerClosed {
+			log.Fatal(err)
+		}
+	}()
+	code := m.Run()
+	os.Unsetenv("SOFT_SERVE_DATA_PATH")
+	os.Unsetenv("SOFT_SERVE_GIT_MAX_CONNECTIONS")
+	os.Unsetenv("SOFT_SERVE_GIT_MAX_TIMEOUT")
+	os.Unsetenv("SOFT_SERVE_GIT_IDLE_TIMEOUT")
+	os.Unsetenv("SOFT_SERVE_GIT_LISTEN_ADDR")
+	_ = d.Close()
+	os.Exit(code)
+}
+
+func TestIdleTimeout(t *testing.T) {
+	c, err := net.Dial("tcp", testDaemon.addr)
+	if err != nil {
+		t.Fatal(err)
+	}
+	time.Sleep(2 * time.Second)
+	out, err := readPktline(c)
+	if err != nil && !errors.Is(err, io.EOF) {
+		t.Fatalf("expected nil, got error: %v", err)
+	}
+	if out != ErrTimeout.Error() {
+		t.Fatalf("expected %q error, got %q", ErrTimeout, out)
+	}
+}
+
+func TestInvalidRepo(t *testing.T) {
+	c, err := net.Dial("tcp", testDaemon.addr)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := pktline.NewEncoder(c).EncodeString("git-upload-pack /test.git\x00"); err != nil {
+		t.Fatalf("expected nil, got error: %v", err)
+	}
+	out, err := readPktline(c)
+	if err != nil {
+		t.Fatalf("expected nil, got error: %v", err)
+	}
+	if out != ErrInvalidRepo.Error() {
+		t.Fatalf("expected %q error, got %q", ErrInvalidRepo, out)
+	}
+}
+
+func readPktline(c net.Conn) (string, error) {
+	buf, err := io.ReadAll(c)
+	if err != nil {
+		return "", err
+	}
+	pktout := pktline.NewScanner(bytes.NewReader(buf))
+	if !pktout.Scan() {
+		return "", pktout.Err()
+	}
+	return strings.TrimSpace(string(pktout.Bytes())), nil
+}

server/git.go πŸ”—

@@ -0,0 +1,162 @@
+package server
+
+import (
+	"errors"
+	"fmt"
+	"io"
+	"log"
+	"os"
+	"path/filepath"
+
+	"github.com/charmbracelet/soft-serve/git"
+	"github.com/go-git/go-git/v5/plumbing/format/pktline"
+)
+
+var (
+
+	// ErrNotAuthed represents unauthorized access.
+	ErrNotAuthed = errors.New("you are not authorized to do this")
+
+	// ErrSystemMalfunction represents a general system error returned to clients.
+	ErrSystemMalfunction = errors.New("something went wrong")
+
+	// ErrInvalidRepo represents an attempt to access a non-existent repo.
+	ErrInvalidRepo = errors.New("invalid repo")
+
+	// ErrInvalidRequest represents an invalid request.
+	ErrInvalidRequest = errors.New("invalid request")
+
+	// ErrMaxConnections represents a maximum connection limit being reached.
+	ErrMaxConnections = errors.New("too many connections, try again later")
+
+	// ErrTimeout is returned when the maximum read timeout is exceeded.
+	ErrTimeout = errors.New("I/O timeout reached")
+)
+
+// Git protocol commands.
+const (
+	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 {
+	exists, err := fileExists(repoDir)
+	if !exists {
+		return ErrInvalidRepo
+	}
+	if err != nil {
+		return err
+	}
+	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 {
+	exists, err := fileExists(repoDir)
+	if !exists {
+		return ErrInvalidRepo
+	}
+	if err != nil {
+		return err
+	}
+	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 {
+		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 {
+	c := git.NewCommand(args...)
+	return c.RunInDirWithOptions(dir, git.RunInDirOptions{
+		Stdin:  in,
+		Stdout: out,
+		Stderr: err,
+	})
+}
+
+// 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 {
+		log.Printf("git: error writing pkt-line message: %s", err)
+	}
+	if err := pkt.Flush(); err != nil {
+		log.Printf("git: error flushing pkt-line message: %s", err)
+	}
+}
+
+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
+}
+
+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 {
+		return err
+	}
+	brs, err := r.Branches()
+	if err != nil {
+		return err
+	}
+	if len(brs) == 0 {
+		return fmt.Errorf("no branches found")
+	}
+	// 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])
+		if err != nil {
+			return err
+		}
+	}
+	if err != nil && err != git.ErrReferenceNotExist {
+		return err
+	}
+	return nil
+}

server/git/daemon/conn.go πŸ”—

@@ -0,0 +1,55 @@
+package daemon
+
+import (
+	"context"
+	"net"
+	"time"
+)
+
+type serverConn struct {
+	net.Conn
+
+	idleTimeout   time.Duration
+	maxDeadline   time.Time
+	closeCanceler context.CancelFunc
+}
+
+func (c *serverConn) Write(p []byte) (n int, err error) {
+	c.updateDeadline()
+	n, err = c.Conn.Write(p)
+	if _, isNetErr := err.(net.Error); isNetErr && c.closeCanceler != nil {
+		c.closeCanceler()
+	}
+	return
+}
+
+func (c *serverConn) Read(b []byte) (n int, err error) {
+	c.updateDeadline()
+	n, err = c.Conn.Read(b)
+	if _, isNetErr := err.(net.Error); isNetErr && c.closeCanceler != nil {
+		c.closeCanceler()
+	}
+	return
+}
+
+func (c *serverConn) Close() (err error) {
+	err = c.Conn.Close()
+	if c.closeCanceler != nil {
+		c.closeCanceler()
+	}
+	return
+}
+
+func (c *serverConn) updateDeadline() {
+	switch {
+	case c.idleTimeout > 0:
+		idleDeadline := time.Now().Add(c.idleTimeout)
+		if idleDeadline.Unix() < c.maxDeadline.Unix() || c.maxDeadline.IsZero() {
+			c.Conn.SetDeadline(idleDeadline)
+			return
+		}
+		fallthrough
+	default:
+		c.Conn.SetDeadline(c.maxDeadline)
+	}
+}

server/middleware_test.go πŸ”—

@@ -1,38 +0,0 @@
-package server
-
-import (
-	"os"
-	"testing"
-
-	"github.com/charmbracelet/soft-serve/config"
-	sconfig "github.com/charmbracelet/soft-serve/server/config"
-	"github.com/charmbracelet/wish/testsession"
-	"github.com/gliderlabs/ssh"
-	"github.com/matryer/is"
-)
-
-var ()
-
-func TestMiddleware(t *testing.T) {
-	t.Cleanup(func() {
-		os.RemoveAll("testmiddleware")
-	})
-	is := is.New(t)
-	appCfg, err := config.NewConfig(&sconfig.Config{
-		Host:     "localhost",
-		Port:     22223,
-		RepoPath: "testmiddleware/repos",
-		KeyPath:  "testmiddleware/key",
-	})
-	is.NoErr(err)
-	_ = testsession.New(t, &ssh.Server{
-		Handler: softMiddleware(appCfg)(func(s ssh.Session) {
-			t.Run("TestCatConfig", func(t *testing.T) {
-				_, err := s.Write([]byte("cat config/config.json"))
-				if err == nil {
-					t.Errorf("Expected error, got nil")
-				}
-			})
-		}),
-	}, nil)
-}

server/server.go πŸ”—

@@ -2,29 +2,26 @@ package server
 
 import (
 	"context"
-	"fmt"
-	"net"
-	"path/filepath"
-	"strings"
 
 	"github.com/charmbracelet/log"
 
-	appCfg "github.com/charmbracelet/soft-serve/config"
+	"github.com/charmbracelet/soft-serve/server/backend"
 	"github.com/charmbracelet/soft-serve/server/config"
-	"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"
-	"github.com/muesli/termenv"
+	"github.com/charmbracelet/ssh"
+	"golang.org/x/sync/errgroup"
+)
+
+var (
+	logger = log.WithPrefix("server")
 )
 
 // Server is the Soft Serve server.
 type Server struct {
-	SSHServer *ssh.Server
+	SSHServer *SSHServer
+	GitDaemon *GitDaemon
 	Config    *config.Config
-	config    *appCfg.Config
+	Backend   backend.Backend
+	Access    backend.AccessMethod
 }
 
 // NewServer returns a new *ssh.Server configured to serve Soft Serve. The SSH
@@ -32,85 +29,66 @@ type Server struct {
 // key can be provided with authKey. If authKey is provided, access will be
 // restricted to that key. If authKey is not provided, the server will be
 // publicly writable until configured otherwise by cloning the `config` repo.
-func NewServer(cfg *config.Config) *Server {
-	ac, err := appCfg.NewConfig(cfg)
-	if err != nil {
-		log.Fatal(err)
+func NewServer(cfg *config.Config) (*Server, error) {
+	var err error
+	srv := &Server{
+		Config:  cfg,
+		Backend: cfg.Backend,
+		Access:  cfg.Access,
 	}
-	mw := []wish.Middleware{
-		rm.MiddlewareWithLogger(
-			cfg.ErrorLog,
-			softMiddleware(ac),
-			bm.MiddlewareWithProgramHandler(SessionHandler(ac), termenv.ANSI256),
-			gm.Middleware(cfg.RepoPath, ac),
-			// Note: disable pushing to subdirectories as it can create
-			// conflicts with existing repos. This only affects the git
-			// middleware.
-			//
-			// This is related to
-			// https://github.com/charmbracelet/soft-serve/issues/120
-			// https://github.com/charmbracelet/wish/commit/8808de520d3ea21931f13113c6b0b6d0141272d4
-			func(sh ssh.Handler) ssh.Handler {
-				return func(s ssh.Session) {
-					cmds := s.Command()
-					if len(cmds) == 2 && strings.HasPrefix(cmds[0], "git") {
-						repo := strings.TrimSuffix(strings.TrimPrefix(cmds[1], "/"), "/")
-						repo = filepath.Clean(repo)
-						if n := strings.Count(repo, "/"); n != 0 {
-							wish.Fatalln(s, fmt.Errorf("invalid repo path: subdirectories not allowed"))
-							return
-						}
-					}
-					sh(s)
-				}
-			},
-			lm.MiddlewareWithLogger(log.StandardLog(log.StandardLogOptions{ForceLevel: log.DebugLevel})),
-		),
-	}
-	s, err := wish.NewServer(
-		ssh.PublicKeyAuth(ac.PublicKeyHandler),
-		ssh.KeyboardInteractiveAuth(ac.KeyboardInteractiveHandler),
-		wish.WithAddress(fmt.Sprintf("%s:%d", cfg.BindAddr, cfg.Port)),
-		wish.WithHostKeyPath(cfg.KeyPath),
-		wish.WithMiddleware(mw...),
-	)
+	srv.SSHServer, err = NewSSHServer(cfg)
 	if err != nil {
-		log.Fatal(err)
+		return nil, err
 	}
-	return &Server{
-		SSHServer: s,
-		Config:    cfg,
-		config:    ac,
+
+	srv.GitDaemon, err = NewGitDaemon(cfg)
+	if err != nil {
+		return nil, err
 	}
-}
 
-// Reload reloads the server configuration.
-func (srv *Server) Reload() error {
-	return srv.config.Reload()
+	return srv, nil
 }
 
 // Start starts the SSH server.
-func (srv *Server) Start() error {
-	if err := srv.SSHServer.ListenAndServe(); err != ssh.ErrServerClosed {
-		return err
-	}
-	return nil
-}
-
-// Serve serves the SSH server using the provided listener.
-func (srv *Server) Serve(l net.Listener) error {
-	if err := srv.SSHServer.Serve(l); err != ssh.ErrServerClosed {
-		return err
-	}
-	return nil
+func (s *Server) Start() error {
+	var errg errgroup.Group
+	errg.Go(func() error {
+		log.Print("Starting Git daemon", "addr", s.Config.Git.ListenAddr)
+		if err := s.GitDaemon.Start(); err != ErrServerClosed {
+			return err
+		}
+		return nil
+	})
+	errg.Go(func() error {
+		log.Print("Starting SSH server", "addr", s.Config.SSH.ListenAddr)
+		if err := s.SSHServer.ListenAndServe(); err != ssh.ErrServerClosed {
+			return err
+		}
+		return nil
+	})
+	return errg.Wait()
 }
 
 // Shutdown lets the server gracefully shutdown.
-func (srv *Server) Shutdown(ctx context.Context) error {
-	return srv.SSHServer.Shutdown(ctx)
+func (s *Server) Shutdown(ctx context.Context) error {
+	var errg errgroup.Group
+	errg.Go(func() error {
+		return s.GitDaemon.Shutdown(ctx)
+	})
+	errg.Go(func() error {
+		return s.SSHServer.Shutdown(ctx)
+	})
+	return errg.Wait()
 }
 
 // Close closes the SSH server.
-func (srv *Server) Close() error {
-	return srv.SSHServer.Close()
+func (s *Server) Close() error {
+	var errg errgroup.Group
+	errg.Go(func() error {
+		return s.SSHServer.Close()
+	})
+	errg.Go(func() error {
+		return s.GitDaemon.Close()
+	})
+	return errg.Wait()
 }

server/server_test.go πŸ”—

@@ -2,116 +2,64 @@ package server
 
 import (
 	"fmt"
-	"os"
+	"net"
 	"path/filepath"
+	"strings"
 	"testing"
 
 	"github.com/charmbracelet/keygen"
+	"github.com/charmbracelet/soft-serve/server/backend/noop"
 	"github.com/charmbracelet/soft-serve/server/config"
-	"github.com/gliderlabs/ssh"
-	"github.com/go-git/go-git/v5"
-	gconfig "github.com/go-git/go-git/v5/config"
-	"github.com/go-git/go-git/v5/plumbing/object"
-	gssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
+	"github.com/charmbracelet/ssh"
 	"github.com/matryer/is"
-	cssh "golang.org/x/crypto/ssh"
+	gossh "golang.org/x/crypto/ssh"
 )
 
-var (
-	testdata = "testdata"
-	cfg      = &config.Config{
-		BindAddr: "",
-		Host:     "localhost",
-		Port:     22222,
-		RepoPath: fmt.Sprintf("%s/repos", testdata),
-		KeyPath:  fmt.Sprintf("%s/key", testdata),
-	}
-	pkPath = ""
-)
-
-func TestServer(t *testing.T) {
-	t.Cleanup(func() {
-		os.RemoveAll(testdata)
-	})
-	is := is.New(t)
-	_, pkPath = createKeyPair(t)
-	s := setupServer(t)
-	err := s.Reload()
-	is.NoErr(err)
-	t.Run("TestPushRepo", testPushRepo)
-	t.Run("TestCloneRepo", testCloneRepo)
-}
-
-func testPushRepo(t *testing.T) {
-	is := is.New(t)
-	rp := t.TempDir()
-	r, err := git.PlainInit(rp, false)
-	is.NoErr(err)
-	wt, err := r.Worktree()
-	is.NoErr(err)
-	_, err = wt.Filesystem.Create("testfile")
-	is.NoErr(err)
-	_, err = wt.Add("testfile")
-	is.NoErr(err)
-	author := &object.Signature{
-		Name:  "test",
-		Email: "",
-	}
-	_, err = wt.Commit("test commit", &git.CommitOptions{
-		All:       true,
-		Author:    author,
-		Committer: author,
-	})
-	is.NoErr(err)
-	_, err = r.CreateRemote(&gconfig.RemoteConfig{
-		Name: "origin",
-		URLs: []string{fmt.Sprintf("ssh://%s:%d/%s", cfg.Host, cfg.Port, "testrepo")},
-	})
-	auth, err := gssh.NewPublicKeysFromFile("git", pkPath, "")
-	is.NoErr(err)
-	auth.HostKeyCallbackHelper = gssh.HostKeyCallbackHelper{
-		HostKeyCallback: cssh.InsecureIgnoreHostKey(),
-	}
-	err = r.Push(&git.PushOptions{
-		RemoteName: "origin",
-		Auth:       auth,
-	})
-	is.NoErr(err)
+func randomPort() int {
+	addr, _ := net.Listen("tcp", ":0") //nolint:gosec
+	_ = addr.Close()
+	return addr.Addr().(*net.TCPAddr).Port
 }
 
-func testCloneRepo(t *testing.T) {
-	is := is.New(t)
-	auth, err := gssh.NewPublicKeysFromFile("git", pkPath, "")
-	is.NoErr(err)
-	auth.HostKeyCallbackHelper = gssh.HostKeyCallbackHelper{
-		HostKeyCallback: cssh.InsecureIgnoreHostKey(),
+func setupServer(tb testing.TB) (*Server, *config.Config, string) {
+	tb.Helper()
+	tb.Log("creating keypair")
+	pub, pkPath := createKeyPair(tb)
+	dp := tb.TempDir()
+	sshPort := fmt.Sprintf(":%d", randomPort())
+	tb.Setenv("SOFT_SERVE_DATA_PATH", dp)
+	tb.Setenv("SOFT_SERVE_INITIAL_ADMIN_KEY", authorizedKey(pub))
+	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)
 	}
-	dst := t.TempDir()
-	_, err = git.PlainClone(dst, false, &git.CloneOptions{
-		URL:  fmt.Sprintf("ssh://%s:%d/config", cfg.Host, cfg.Port),
-		Auth: auth,
-	})
-	is.NoErr(err)
-}
-
-func setupServer(t *testing.T) *Server {
-	s := NewServer(cfg)
 	go func() {
+		tb.Log("starting server")
 		s.Start()
 	}()
-	t.Cleanup(func() {
+	tb.Cleanup(func() {
 		s.Close()
 	})
-	return s
+	return s, cfg, pkPath
 }
 
-func createKeyPair(t *testing.T) (ssh.PublicKey, string) {
-	is := is.New(t)
-	t.Helper()
-	keyDir := t.TempDir()
+func createKeyPair(tb testing.TB) (ssh.PublicKey, string) {
+	tb.Helper()
+	is := is.New(tb)
+	keyDir := tb.TempDir()
 	kp, err := keygen.NewWithWrite(filepath.Join(keyDir, "id"), nil, keygen.Ed25519)
 	is.NoErr(err)
 	pubkey, _, _, _, err := ssh.ParseAuthorizedKey(kp.PublicKey())
 	is.NoErr(err)
 	return pubkey, filepath.Join(keyDir, "id_ed25519")
 }
+
+func authorizedKey(pk ssh.PublicKey) string {
+	return strings.TrimSpace(string(gossh.MarshalAuthorizedKey(pk)))
+}

server/session.go πŸ”—

@@ -5,21 +5,18 @@ import (
 
 	"github.com/aymanbagabas/go-osc52"
 	tea "github.com/charmbracelet/bubbletea"
-	appCfg "github.com/charmbracelet/soft-serve/config"
+	"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/ui"
 	"github.com/charmbracelet/soft-serve/ui/common"
-	"github.com/charmbracelet/soft-serve/ui/keymap"
-	"github.com/charmbracelet/soft-serve/ui/styles"
+	"github.com/charmbracelet/ssh"
 	"github.com/charmbracelet/wish"
 	bm "github.com/charmbracelet/wish/bubbletea"
-	gm "github.com/charmbracelet/wish/git"
-	"github.com/gliderlabs/ssh"
-	zone "github.com/lrstanley/bubblezone"
 )
 
 // SessionHandler is the soft-serve bubbletea ssh session handler.
-func SessionHandler(ac *appCfg.Config) bm.ProgramHandler {
+func SessionHandler(cfg *config.Config) bm.ProgramHandler {
 	return func(s ssh.Session) *tea.Program {
 		pty, _, active := s.Pty()
 		if !active {
@@ -29,32 +26,18 @@ func SessionHandler(ac *appCfg.Config) bm.ProgramHandler {
 		initialRepo := ""
 		if len(cmd) == 1 {
 			initialRepo = cmd[0]
-			auth := ac.AuthRepo(initialRepo, s.PublicKey())
-			if auth < gm.ReadOnlyAccess {
+			auth := cfg.Access.AccessLevel(initialRepo, s.PublicKey())
+			if auth < backend.ReadOnlyAccess {
 				wish.Fatalln(s, cm.ErrUnauthorized)
 				return nil
 			}
 		}
-		if ac.Cfg.Callbacks != nil {
-			ac.Cfg.Callbacks.Tui("new session")
-		}
 		envs := s.Environ()
 		envs = append(envs, fmt.Sprintf("TERM=%s", pty.Term))
 		output := osc52.NewOutput(s, envs)
-		c := common.Common{
-			Copy:   output,
-			Styles: styles.DefaultStyles(),
-			KeyMap: keymap.DefaultKeyMap(),
-			Width:  pty.Window.Width,
-			Height: pty.Window.Height,
-			Zone:   zone.New(),
-		}
-		m := ui.New(
-			ac,
-			s,
-			c,
-			initialRepo,
-		)
+		c := common.NewCommon(s.Context(), output, pty.Window.Width, pty.Window.Height)
+		c.SetValue(common.ConfigKey, cfg)
+		m := ui.New(c, initialRepo)
 		p := tea.NewProgram(m,
 			tea.WithInput(s),
 			tea.WithOutput(s),

server/session_test.go πŸ”—

@@ -1,19 +1,16 @@
 package server
 
 import (
-	"bytes"
 	"errors"
+	"fmt"
 	"os"
-	"strings"
 	"testing"
 	"time"
 
-	appCfg "github.com/charmbracelet/soft-serve/config"
-	cm "github.com/charmbracelet/soft-serve/server/cmd"
 	"github.com/charmbracelet/soft-serve/server/config"
+	"github.com/charmbracelet/ssh"
 	bm "github.com/charmbracelet/wish/bubbletea"
 	"github.com/charmbracelet/wish/testsession"
-	"github.com/gliderlabs/ssh"
 	"github.com/matryer/is"
 	"github.com/muesli/termenv"
 	gossh "golang.org/x/crypto/ssh"
@@ -21,53 +18,40 @@ import (
 
 func TestSession(t *testing.T) {
 	is := is.New(t)
-	t.Run("unauthorized repo access", func(t *testing.T) {
-		var out bytes.Buffer
-		s := setup(t)
-		s.Stderr = &out
-		defer s.Close()
-		err := s.RequestPty("xterm", 80, 40, nil)
-		is.NoErr(err)
-		err = s.Run("config")
-		// Session writes error and exits
-		is.True(strings.Contains(out.String(), cm.ErrUnauthorized.Error()))
-		var ee *gossh.ExitError
-		is.True(errors.As(err, &ee) && ee.ExitStatus() == 1)
-	})
 	t.Run("authorized repo access", func(t *testing.T) {
 		s := setup(t)
 		s.Stderr = os.Stderr
 		defer s.Close()
 		err := s.RequestPty("xterm", 80, 40, nil)
 		is.NoErr(err)
-		in, err := s.StdinPipe()
-		is.NoErr(err)
 		go func() {
-			<-time.After(time.Second)
-			// Send "q" to exit the config command
-			in.Write([]byte("q"))
+			time.Sleep(1 * time.Second)
+			s.Signal(gossh.SIGTERM)
+			// FIXME: exit with code 0 instead of forcibly closing the session
+			s.Close()
 		}()
-		err = s.Shell()
-		is.NoErr(err)
+		err = s.Run("test")
+		var ee *gossh.ExitMissingError
+		is.True(errors.As(err, &ee))
 	})
 }
 
 func setup(tb testing.TB) *gossh.Session {
-	is := is.New(tb)
 	tb.Helper()
-	cfg.RepoPath = tb.TempDir()
-	ac, err := appCfg.NewConfig(&config.Config{
-		Port:     22226,
-		KeyPath:  tb.TempDir(),
-		RepoPath: tb.TempDir(),
-		InitialAdminKeys: []string{
-			"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMJlb/qf2B2kMNdBxfpCQqI2ctPcsOkdZGVh5zTRhKtH",
-		},
+	is := is.New(tb)
+	dp := tb.TempDir()
+	is.NoErr(os.Setenv("SOFT_SERVE_DATA_PATH", dp))
+	is.NoErr(os.Setenv("SOFT_SERVE_GIT_LISTEN_ADDR", ":9418"))
+	is.NoErr(os.Setenv("SOFT_SERVE_SSH_LISTEN_ADDR", fmt.Sprintf(":%d", randomPort())))
+	tb.Cleanup(func() {
+		is.NoErr(os.Unsetenv("SOFT_SERVE_DATA_PATH"))
+		is.NoErr(os.Unsetenv("SOFT_SERVE_GIT_LISTEN_ADDR"))
+		is.NoErr(os.Unsetenv("SOFT_SERVE_SSH_LISTEN_ADDR"))
+		is.NoErr(os.RemoveAll(dp))
 	})
-	ac.AnonAccess = "read-only"
-	is.NoErr(err)
+	cfg := config.DefaultConfig()
 	return testsession.New(tb, &ssh.Server{
-		Handler: bm.MiddlewareWithProgramHandler(SessionHandler(ac), termenv.ANSI256)(func(s ssh.Session) {
+		Handler: bm.MiddlewareWithProgramHandler(SessionHandler(cfg), termenv.ANSI256)(func(s ssh.Session) {
 			_, _, active := s.Pty()
 			tb.Logf("PTY active %v", active)
 			tb.Log(s.Command())

server/ssh.go πŸ”—

@@ -0,0 +1,157 @@
+package server
+
+import (
+	"errors"
+	"fmt"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"github.com/charmbracelet/log"
+	"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/ssh"
+	"github.com/charmbracelet/wish"
+	bm "github.com/charmbracelet/wish/bubbletea"
+	lm "github.com/charmbracelet/wish/logging"
+	rm "github.com/charmbracelet/wish/recover"
+	"github.com/muesli/termenv"
+	gossh "golang.org/x/crypto/ssh"
+)
+
+// SSHServer is a SSH server that implements the git protocol.
+type SSHServer struct {
+	*ssh.Server
+	cfg *config.Config
+}
+
+// NewSSHServer returns a new SSHServer.
+func NewSSHServer(cfg *config.Config) (*SSHServer, error) {
+	var err error
+	s := &SSHServer{cfg: cfg}
+	logger := logger.StandardLog(log.StandardLogOptions{ForceLevel: log.DebugLevel})
+	mw := []wish.Middleware{
+		rm.MiddlewareWithLogger(
+			logger,
+			// BubbleTea middleware.
+			bm.MiddlewareWithProgramHandler(SessionHandler(cfg), termenv.ANSI256),
+			// Command middleware must come after the git middleware.
+			cm.Middleware(cfg),
+			// Git middleware.
+			s.Middleware(cfg),
+			lm.MiddlewareWithLogger(logger),
+		),
+	}
+	s.Server, err = wish.NewServer(
+		ssh.PublicKeyAuth(s.PublicKeyHandler),
+		ssh.KeyboardInteractiveAuth(s.KeyboardInteractiveHandler),
+		wish.WithAddress(cfg.SSH.ListenAddr),
+		wish.WithHostKeyPath(cfg.SSH.KeyPath),
+		wish.WithMiddleware(mw...),
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	if cfg.SSH.MaxTimeout > 0 {
+		s.Server.MaxTimeout = time.Duration(cfg.SSH.MaxTimeout) * time.Second
+	}
+	if cfg.SSH.IdleTimeout > 0 {
+		s.Server.IdleTimeout = time.Duration(cfg.SSH.IdleTimeout) * time.Second
+	}
+
+	return s, nil
+}
+
+// PublicKeyAuthHandler handles public key authentication.
+func (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) bool {
+	al := s.cfg.Access.AccessLevel("", pk)
+	logger.Debug("publickey handler", "level", al)
+	return al > backend.NoAccess
+}
+
+// KeyboardInteractiveHandler handles keyboard interactive authentication.
+func (s *SSHServer) KeyboardInteractiveHandler(_ ssh.Context, _ gossh.KeyboardInteractiveChallenge) bool {
+	return true
+}
+
+// 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 (s *SSHServer) Middleware(cfg *config.Config) wish.Middleware {
+	return func(sh ssh.Handler) ssh.Handler {
+		return func(s ssh.Session) {
+			func() {
+				cmd := s.Command()
+				if len(cmd) >= 2 && strings.HasPrefix(cmd[0], "git") {
+					gc := cmd[0]
+					// repo should be in the form of "repo.git"
+					repo := sanitizeRepoName(cmd[1])
+					name := repo
+					if strings.Contains(repo, "/") {
+						log.Printf("invalid repo: %s", repo)
+						sshFatal(s, fmt.Errorf("%s: %s", ErrInvalidRepo, "user repos not supported"))
+						return
+					}
+					pk := s.PublicKey()
+					access := cfg.Access.AccessLevel(name, pk)
+					// git bare repositories should end in ".git"
+					// https://git-scm.com/docs/gitrepository-layout
+					repo = strings.TrimSuffix(repo, ".git") + ".git"
+					// FIXME: determine repositories path
+					repoDir := filepath.Join(cfg.DataPath, "repos", repo)
+					switch gc {
+					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)
+								sshFatal(s, err)
+								return
+							}
+						}
+						if err := ReceivePack(s, s, s.Stderr(), repoDir); err != nil {
+							sshFatal(s, ErrSystemMalfunction)
+						}
+						return
+					case UploadPackBin, UploadArchiveBin:
+						if access < backend.ReadOnlyAccess {
+							sshFatal(s, ErrNotAuthed)
+							return
+						}
+						gitPack := UploadPack
+						if gc == UploadArchiveBin {
+							gitPack = UploadArchive
+						}
+						err := gitPack(s, s, s.Stderr(), repoDir)
+						if errors.Is(err, ErrInvalidRepo) {
+							sshFatal(s, ErrInvalidRepo)
+						} else if err != nil {
+							sshFatal(s, ErrSystemMalfunction)
+						}
+					}
+				}
+			}()
+			sh(s)
+		}
+	}
+}
+
+// sshFatal prints to the session's STDOUT as a git response and exit 1.
+func sshFatal(s ssh.Session, v ...interface{}) {
+	WritePktline(s, v...)
+	s.Exit(1) // nolint: errcheck
+}
+
+func sanitizeRepoName(repo string) string {
+	repo = strings.TrimPrefix(repo, "/")
+	repo = filepath.Clean(repo)
+	repo = strings.TrimSuffix(repo, ".git")
+	return repo
+}

ui/common/common.go πŸ”—

@@ -1,20 +1,56 @@
 package common
 
 import (
+	"context"
+
 	"github.com/aymanbagabas/go-osc52"
+	"github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/server/config"
 	"github.com/charmbracelet/soft-serve/ui/keymap"
 	"github.com/charmbracelet/soft-serve/ui/styles"
+	"github.com/charmbracelet/ssh"
 	zone "github.com/lrstanley/bubblezone"
 )
 
+type contextKey struct {
+	name string
+}
+
+// Keys to use for context.Context.
+var (
+	ConfigKey = &contextKey{"config"}
+	RepoKey   = &contextKey{"repo"}
+)
+
 // Common is a struct all components should embed.
 type Common struct {
-	Copy   *osc52.Output
-	Styles *styles.Styles
-	KeyMap *keymap.KeyMap
-	Width  int
-	Height int
-	Zone   *zone.Manager
+	ctx           context.Context
+	Width, Height int
+	Styles        *styles.Styles
+	KeyMap        *keymap.KeyMap
+	Copy          *osc52.Output
+	Zone          *zone.Manager
+}
+
+// NewCommon returns a new Common struct.
+func NewCommon(ctx context.Context, copy *osc52.Output, width, height int) Common {
+	if ctx == nil {
+		ctx = context.TODO()
+	}
+	return Common{
+		ctx:    ctx,
+		Width:  width,
+		Height: height,
+		Copy:   copy,
+		Styles: styles.DefaultStyles(),
+		KeyMap: keymap.DefaultKeyMap(),
+		Zone:   zone.New(),
+	}
+}
+
+// SetValue sets a value in the context.
+func (c *Common) SetValue(key, value interface{}) {
+	c.ctx = context.WithValue(c.ctx, key, value)
 }
 
 // SetSize sets the width and height of the common struct.
@@ -22,3 +58,30 @@ func (c *Common) SetSize(width, height int) {
 	c.Width = width
 	c.Height = height
 }
+
+// Config returns the server config.
+func (c *Common) Config() *config.Config {
+	v := c.ctx.Value(ConfigKey)
+	if cfg, ok := v.(*config.Config); ok {
+		return cfg
+	}
+	return nil
+}
+
+// Repo returns the repository.
+func (c *Common) Repo() *git.Repository {
+	v := c.ctx.Value(RepoKey)
+	if r, ok := v.(*git.Repository); ok {
+		return r
+	}
+	return nil
+}
+
+// PublicKey returns the public key.
+func (c *Common) PublicKey() ssh.PublicKey {
+	v := c.ctx.Value(ssh.ContextKeyPublicKey)
+	if p, ok := v.(ssh.PublicKey); ok {
+		return p
+	}
+	return nil
+}

ui/common/error.go πŸ”—

@@ -1,6 +1,13 @@
 package common
 
-import tea "github.com/charmbracelet/bubbletea"
+import (
+	"errors"
+
+	tea "github.com/charmbracelet/bubbletea"
+)
+
+// ErrMissingRepo indicates that the requested repository could not be found.
+var ErrMissingRepo = errors.New("missing repo")
 
 // ErrorMsg is a Bubble Tea message that represents an error.
 type ErrorMsg error

ui/common/utils.go πŸ”—

@@ -1,6 +1,10 @@
 package common
 
-import "github.com/muesli/reflow/truncate"
+import (
+	"fmt"
+
+	"github.com/muesli/reflow/truncate"
+)
 
 // TruncateString is a convenient wrapper around truncate.TruncateString.
 func TruncateString(s string, max int) string {
@@ -9,3 +13,12 @@ func TruncateString(s string, max int) string {
 	}
 	return truncate.StringWithTail(s, uint(max), "…")
 }
+
+// RepoURL returns the URL of the repository.
+func RepoURL(host string, port string, name string) string {
+	p := ""
+	if port != "22" {
+		p += ":" + port
+	}
+	return fmt.Sprintf("git clone ssh://%s/%s", host+p, name)
+}

ui/components/code/code.go πŸ”—

@@ -47,7 +47,7 @@ func New(c common.Common, content, extension string) *Code {
 		content:        content,
 		extension:      extension,
 		Viewport:       vp.New(c),
-		NoContentStyle: c.Styles.CodeNoContent.Copy(),
+		NoContentStyle: c.Styles.NoContent.Copy(),
 		LineDigitStyle: lineDigitStyle,
 		LineBarStyle:   lineBarStyle,
 	}

ui/git.go πŸ”—

@@ -1,25 +0,0 @@
-package ui
-
-import (
-	"github.com/charmbracelet/soft-serve/config"
-	"github.com/charmbracelet/soft-serve/ui/git"
-)
-
-// source is a wrapper around config.RepoSource that implements git.GitRepoSource.
-type source struct {
-	*config.RepoSource
-}
-
-// GetRepo implements git.GitRepoSource.
-func (s *source) GetRepo(name string) (git.GitRepo, error) {
-	return s.RepoSource.GetRepo(name)
-}
-
-// AllRepos implements git.GitRepoSource.
-func (s *source) AllRepos() []git.GitRepo {
-	rs := make([]git.GitRepo, 0)
-	for _, r := range s.RepoSource.AllRepos() {
-		rs = append(rs, r)
-	}
-	return rs
-}

ui/git/git.go πŸ”—

@@ -1,42 +0,0 @@
-package git
-
-import (
-	"errors"
-	"fmt"
-
-	"github.com/charmbracelet/soft-serve/git"
-)
-
-// ErrMissingRepo indicates that the requested repository could not be found.
-var ErrMissingRepo = errors.New("missing repo")
-
-// GitRepo is an interface for Git repositories.
-type GitRepo interface {
-	Repo() string
-	Name() string
-	Description() string
-	Readme() (string, string)
-	HEAD() (*git.Reference, error)
-	Commit(string) (*git.Commit, error)
-	CommitsByPage(*git.Reference, int, int) (git.Commits, error)
-	CountCommits(*git.Reference) (int64, error)
-	Diff(*git.Commit) (*git.Diff, error)
-	References() ([]*git.Reference, error)
-	Tree(*git.Reference, string) (*git.Tree, error)
-	IsPrivate() bool
-}
-
-// GitRepoSource is an interface for Git repository factory.
-type GitRepoSource interface {
-	GetRepo(string) (GitRepo, error)
-	AllRepos() []GitRepo
-}
-
-// RepoURL returns the URL of the repository.
-func RepoURL(host string, port int, name string) string {
-	p := ""
-	if port != 22 {
-		p += fmt.Sprintf(":%d", port)
-	}
-	return fmt.Sprintf("git clone ssh://%s/%s", host+p, name)
-}

ui/pages/repo/empty.go πŸ”—

@@ -0,0 +1,45 @@
+package repo
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/charmbracelet/soft-serve/server/config"
+)
+
+func defaultEmptyRepoMsg(cfg *config.Config, repo string) string {
+	host := cfg.Backend.ServerHost()
+	if cfg.Backend.ServerPort() != "22" {
+		host = fmt.Sprintf("%s:%s", host, cfg.Backend.ServerPort())
+	}
+	repo = strings.TrimSuffix(repo, ".git")
+	return fmt.Sprintf(`# Quick Start
+
+Get started by cloning this repository, add your files, commit, and push.
+
+## Clone this repository.
+
+`+"```"+`sh
+git clone ssh://%[1]s/%[2]s.git
+`+"```"+`
+
+## Creating a new repository on the command line
+
+`+"```"+`sh
+touch README.md
+git init
+git add README.md
+git branch -M main
+git commit -m "first commit"
+git remote add origin ssh://%[1]s/%[2]s.git
+git push -u origin main
+`+"```"+`
+
+## Pushing an existing repository from the command line
+
+`+"```"+`sh
+git remote add origin ssh://%[1]s/%[2]s.git
+git push -u origin main
+`+"```"+`
+`, host, repo)
+}

ui/pages/repo/files.go πŸ”—

@@ -3,16 +3,17 @@ package repo
 import (
 	"errors"
 	"fmt"
+	"log"
 	"path/filepath"
 
 	"github.com/alecthomas/chroma/lexers"
 	"github.com/charmbracelet/bubbles/key"
 	tea "github.com/charmbracelet/bubbletea"
-	ggit "github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/server/backend"
 	"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/git"
 )
 
 type filesView int
@@ -49,9 +50,9 @@ type FileContentMsg struct {
 type Files struct {
 	common         common.Common
 	selector       *selector.Selector
-	ref            *ggit.Reference
+	ref            *git.Reference
 	activeView     filesView
-	repo           git.GitRepo
+	repo           backend.Repository
 	code           *code.Code
 	path           string
 	currentItem    *FileItem
@@ -200,8 +201,7 @@ func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	cmds := make([]tea.Cmd, 0)
 	switch msg := msg.(type) {
 	case RepoMsg:
-		f.repo = git.GitRepo(msg)
-		cmds = append(cmds, f.Init())
+		f.repo = msg
 	case RefMsg:
 		f.ref = msg
 		cmds = append(cmds, f.Init())
@@ -265,6 +265,14 @@ func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				}
 			}
 		}
+	case EmptyRepoMsg:
+		f.ref = nil
+		f.path = ""
+		f.currentItem = nil
+		f.activeView = filesViewFiles
+		f.lastSelected = make([]int, 0)
+		f.selector.Select(0)
+		cmds = append(cmds, f.setItems([]selector.IdentifiableItem{}))
 	}
 	switch f.activeView {
 	case filesViewFiles:
@@ -320,14 +328,21 @@ func (f *Files) updateFilesCmd() tea.Msg {
 	files := make([]selector.IdentifiableItem, 0)
 	dirs := make([]selector.IdentifiableItem, 0)
 	if f.ref == nil {
+		log.Printf("ui: files: ref is nil")
 		return common.ErrorMsg(errNoRef)
 	}
-	t, err := f.repo.Tree(f.ref, f.path)
+	r, err := f.repo.Repository()
+	if err != nil {
+		return common.ErrorMsg(err)
+	}
+	t, err := r.TreePath(f.ref, f.path)
 	if err != nil {
+		log.Printf("ui: files: error getting tree %v", err)
 		return common.ErrorMsg(err)
 	}
 	ents, err := t.Entries()
 	if err != nil {
+		log.Printf("ui: files: error listing files %v", err)
 		return common.ErrorMsg(err)
 	}
 	ents.Sort()
@@ -347,6 +362,7 @@ func (f *Files) selectTreeCmd() tea.Msg {
 		f.selector.Select(0)
 		return f.updateFilesCmd()
 	}
+	log.Printf("ui: files: current item is not a tree")
 	return common.ErrorMsg(errNoFileSelected)
 }
 
@@ -355,25 +371,30 @@ func (f *Files) selectFileCmd() tea.Msg {
 	if i != nil && !i.entry.IsTree() {
 		fi := i.entry.File()
 		if i.Mode().IsDir() || f == nil {
+			log.Printf("ui: files: current item is not a file")
 			return common.ErrorMsg(errInvalidFile)
 		}
 		bin, err := fi.IsBinary()
 		if err != nil {
 			f.path = filepath.Dir(f.path)
+			log.Printf("ui: files: error checking if file is binary %v", err)
 			return common.ErrorMsg(err)
 		}
 		if bin {
 			f.path = filepath.Dir(f.path)
+			log.Printf("ui: files: file is binary")
 			return common.ErrorMsg(errBinaryFile)
 		}
 		c, err := fi.Bytes()
 		if err != nil {
 			f.path = filepath.Dir(f.path)
+			log.Printf("ui: files: error reading file %v", err)
 			return common.ErrorMsg(err)
 		}
 		f.lastSelected = append(f.lastSelected, f.selector.Index())
 		return FileContentMsg{string(c), i.entry.Name()}
 	}
+	log.Printf("ui: files: current item is not a file")
 	return common.ErrorMsg(errNoFileSelected)
 }
 
@@ -389,3 +410,9 @@ func (f *Files) deselectItemCmd() tea.Msg {
 	f.selector.Select(index)
 	return msg
 }
+
+func (f *Files) setItems(items []selector.IdentifiableItem) tea.Cmd {
+	return func() tea.Msg {
+		return FileItemsMsg(items)
+	}
+}

ui/pages/repo/log.go πŸ”—

@@ -2,6 +2,7 @@ package repo
 
 import (
 	"fmt"
+	"log"
 	"strings"
 	"time"
 
@@ -10,12 +11,12 @@ import (
 	tea "github.com/charmbracelet/bubbletea"
 	gansi "github.com/charmbracelet/glamour/ansi"
 	"github.com/charmbracelet/lipgloss"
-	ggit "github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/server/backend"
 	"github.com/charmbracelet/soft-serve/ui/common"
 	"github.com/charmbracelet/soft-serve/ui/components/footer"
 	"github.com/charmbracelet/soft-serve/ui/components/selector"
 	"github.com/charmbracelet/soft-serve/ui/components/viewport"
-	"github.com/charmbracelet/soft-serve/ui/git"
 	"github.com/muesli/reflow/wrap"
 	"github.com/muesli/termenv"
 )
@@ -36,10 +37,10 @@ type LogCountMsg int64
 type LogItemsMsg []selector.IdentifiableItem
 
 // LogCommitMsg is a message that contains a git commit.
-type LogCommitMsg *ggit.Commit
+type LogCommitMsg *git.Commit
 
 // LogDiffMsg is a message that contains a git diff.
-type LogDiffMsg *ggit.Diff
+type LogDiffMsg *git.Diff
 
 // Log is a model that displays a list of commits and their diffs.
 type Log struct {
@@ -47,13 +48,13 @@ type Log struct {
 	selector       *selector.Selector
 	vp             *viewport.Viewport
 	activeView     logView
-	repo           git.GitRepo
-	ref            *ggit.Reference
+	repo           backend.Repository
+	ref            *git.Reference
 	count          int64
 	nextPage       int
-	activeCommit   *ggit.Commit
-	selectedCommit *ggit.Commit
-	currentDiff    *ggit.Diff
+	activeCommit   *git.Commit
+	selectedCommit *git.Commit
+	currentDiff    *git.Diff
 	loadingTime    time.Time
 	loading        bool
 	spinner        spinner.Model
@@ -77,9 +78,8 @@ func NewLog(common common.Common) *Log {
 	selector.KeyMap.NextPage = common.KeyMap.NextPage
 	selector.KeyMap.PrevPage = common.KeyMap.PrevPage
 	l.selector = selector
-	s := spinner.New()
-	s.Spinner = spinner.Dot
-	s.Style = common.Styles.Spinner
+	s := spinner.New(spinner.WithSpinner(spinner.Dot),
+		spinner.WithStyle(common.Styles.Spinner))
 	l.spinner = s
 	return l
 }
@@ -189,8 +189,7 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	cmds := make([]tea.Cmd, 0)
 	switch msg := msg.(type) {
 	case RepoMsg:
-		l.repo = git.GitRepo(msg)
-		cmds = append(cmds, l.Init())
+		l.repo = msg
 	case RefMsg:
 		l.ref = msg
 		cmds = append(cmds, l.Init())
@@ -245,6 +244,7 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if l.activeView == logViewDiff {
 			l.activeView = logViewCommits
 			l.selectedCommit = nil
+			cmds = append(cmds, updateStatusBarCmd)
 		}
 	case selector.ActiveMsg:
 		switch sel := msg.IdentifiableItem.(type) {
@@ -299,6 +299,16 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				l.startLoading(),
 			)
 		}
+	case EmptyRepoMsg:
+		l.ref = nil
+		l.loading = false
+		l.activeView = logViewCommits
+		l.nextPage = 0
+		l.count = 0
+		l.activeCommit = nil
+		l.selectedCommit = nil
+		l.selector.Select(0)
+		cmds = append(cmds, l.setItems([]selector.IdentifiableItem{}))
 	}
 	if l.loading {
 		s, cmd := l.spinner.Update(msg)
@@ -326,7 +336,9 @@ func (l *Log) View() string {
 			msg += "s"
 		}
 		msg += "…"
-		return msg
+		return l.common.Styles.SpinnerContainer.Copy().
+			Height(l.common.Height).
+			Render(msg)
 	}
 	switch l.activeView {
 	case logViewCommits:
@@ -374,10 +386,16 @@ func (l *Log) StatusBarInfo() string {
 
 func (l *Log) countCommitsCmd() tea.Msg {
 	if l.ref == nil {
+		log.Printf("ui: log: ref is nil")
 		return common.ErrorMsg(errNoRef)
 	}
-	count, err := l.repo.CountCommits(l.ref)
+	r, err := l.repo.Repository()
+	if err != nil {
+		return common.ErrorMsg(err)
+	}
+	count, err := r.CountCommits(l.ref)
 	if err != nil {
+		log.Printf("ui: error counting commits: %v", err)
 		return common.ErrorMsg(err)
 	}
 	return LogCountMsg(count)
@@ -394,15 +412,21 @@ func (l *Log) updateCommitsCmd() tea.Msg {
 		}
 	}
 	if l.ref == nil {
+		log.Printf("ui: log: ref is nil")
 		return common.ErrorMsg(errNoRef)
 	}
 	items := make([]selector.IdentifiableItem, count)
 	page := l.nextPage
 	limit := l.selector.PerPage()
 	skip := page * limit
+	r, err := l.repo.Repository()
+	if err != nil {
+		return common.ErrorMsg(err)
+	}
 	// CommitsByPage pages start at 1
-	cc, err := l.repo.CommitsByPage(l.ref, page+1, limit)
+	cc, err := r.CommitsByPage(l.ref, page+1, limit)
 	if err != nil {
+		log.Printf("ui: error loading commits: %v", err)
 		return common.ErrorMsg(err)
 	}
 	for i, c := range cc {
@@ -415,15 +439,21 @@ func (l *Log) updateCommitsCmd() tea.Msg {
 	return LogItemsMsg(items)
 }
 
-func (l *Log) selectCommitCmd(commit *ggit.Commit) tea.Cmd {
+func (l *Log) selectCommitCmd(commit *git.Commit) tea.Cmd {
 	return func() tea.Msg {
 		return LogCommitMsg(commit)
 	}
 }
 
 func (l *Log) loadDiffCmd() tea.Msg {
-	diff, err := l.repo.Diff(l.selectedCommit)
+	r, err := l.repo.Repository()
 	if err != nil {
+		log.Printf("ui: error loading diff repository: %v", err)
+		return common.ErrorMsg(err)
+	}
+	diff, err := r.Diff(l.selectedCommit)
+	if err != nil {
+		log.Printf("ui: error loading diff: %v", err)
 		return common.ErrorMsg(err)
 	}
 	return LogDiffMsg(diff)
@@ -436,7 +466,7 @@ func renderCtx() gansi.RenderContext {
 	})
 }
 
-func (l *Log) renderCommit(c *ggit.Commit) string {
+func (l *Log) renderCommit(c *git.Commit) string {
 	s := strings.Builder{}
 	// FIXME: lipgloss prints empty lines when CRLF is used
 	// sanitize commit message from CRLF
@@ -450,7 +480,7 @@ func (l *Log) renderCommit(c *ggit.Commit) string {
 	return wrap.String(s.String(), l.common.Width-2)
 }
 
-func (l *Log) renderSummary(diff *ggit.Diff) string {
+func (l *Log) renderSummary(diff *git.Diff) string {
 	stats := strings.Split(diff.Stats().String(), "\n")
 	for i, line := range stats {
 		ch := strings.Split(line, "|")
@@ -464,7 +494,7 @@ func (l *Log) renderSummary(diff *ggit.Diff) string {
 	return wrap.String(strings.Join(stats, "\n"), l.common.Width-2)
 }
 
-func (l *Log) renderDiff(diff *ggit.Diff) string {
+func (l *Log) renderDiff(diff *git.Diff) string {
 	var s strings.Builder
 	var pr strings.Builder
 	diffChroma := &gansi.CodeBlockElement{
@@ -479,3 +509,9 @@ func (l *Log) renderDiff(diff *ggit.Diff) string {
 	}
 	return wrap.String(s.String(), l.common.Width)
 }
+
+func (l *Log) setItems(items []selector.IdentifiableItem) tea.Cmd {
+	return func() tea.Msg {
+		return LogItemsMsg(items)
+	}
+}

ui/pages/repo/readme.go πŸ”—

@@ -2,22 +2,27 @@ package repo
 
 import (
 	"fmt"
+	"path/filepath"
 
 	"github.com/charmbracelet/bubbles/key"
 	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/soft-serve/server/backend"
 	"github.com/charmbracelet/soft-serve/ui/common"
 	"github.com/charmbracelet/soft-serve/ui/components/code"
-	"github.com/charmbracelet/soft-serve/ui/git"
 )
 
-type ReadmeMsg struct{}
+// ReadmeMsg is a message sent when the readme is loaded.
+type ReadmeMsg struct {
+	Msg tea.Msg
+}
 
 // Readme is the readme component page.
 type Readme struct {
-	common common.Common
-	code   *code.Code
-	ref    RefMsg
-	repo   git.GitRepo
+	common     common.Common
+	code       *code.Code
+	ref        RefMsg
+	repo       backend.Repository
+	readmePath string
 }
 
 // NewReadme creates a new readme model.
@@ -64,15 +69,7 @@ func (r *Readme) FullHelp() [][]key.Binding {
 
 // Init implements tea.Model.
 func (r *Readme) Init() tea.Cmd {
-	if r.repo == nil {
-		return common.ErrorCmd(git.ErrMissingRepo)
-	}
-	rm, rp := r.repo.Readme()
-	r.code.GotoTop()
-	return tea.Batch(
-		r.code.SetContent(rm, rp),
-		r.updateReadmeCmd,
-	)
+	return r.updateReadmeCmd
 }
 
 // Update implements tea.Model.
@@ -80,11 +77,13 @@ func (r *Readme) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	cmds := make([]tea.Cmd, 0)
 	switch msg := msg.(type) {
 	case RepoMsg:
-		r.repo = git.GitRepo(msg)
-		cmds = append(cmds, r.Init())
+		r.repo = msg
 	case RefMsg:
 		r.ref = msg
 		cmds = append(cmds, r.Init())
+	case EmptyRepoMsg:
+		r.code.SetContent(defaultEmptyRepoMsg(r.common.Config(),
+			r.repo.Name()), ".md")
 	}
 	c, cmd := r.code.Update(msg)
 	r.code = c.(*code.Code)
@@ -101,7 +100,11 @@ func (r *Readme) View() string {
 
 // StatusBarValue implements statusbar.StatusBar.
 func (r *Readme) StatusBarValue() string {
-	return ""
+	dir := filepath.Dir(r.readmePath)
+	if dir == "." {
+		return ""
+	}
+	return dir
 }
 
 // StatusBarInfo implements statusbar.StatusBar.
@@ -110,5 +113,19 @@ func (r *Readme) StatusBarInfo() string {
 }
 
 func (r *Readme) updateReadmeCmd() tea.Msg {
-	return ReadmeMsg{}
+	m := ReadmeMsg{}
+	if r.repo == nil {
+		return common.ErrorCmd(common.ErrMissingRepo)
+	}
+	rm, rp, err := backend.Readme(r.repo)
+	if err != nil {
+		return common.ErrorCmd(err)
+	}
+	r.readmePath = rp
+	r.code.GotoTop()
+	cmd := r.code.SetContent(rm, rp)
+	if cmd != nil {
+		m.Msg = cmd()
+	}
+	return m
 }

ui/pages/repo/refs.go πŸ”—

@@ -3,22 +3,27 @@ package repo
 import (
 	"errors"
 	"fmt"
+	"log"
 	"sort"
 	"strings"
 
 	"github.com/charmbracelet/bubbles/key"
 	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/soft-serve/git"
 	ggit "github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/server/backend"
 	"github.com/charmbracelet/soft-serve/ui/common"
 	"github.com/charmbracelet/soft-serve/ui/components/selector"
 	"github.com/charmbracelet/soft-serve/ui/components/tabs"
-	"github.com/charmbracelet/soft-serve/ui/git"
 )
 
 var (
 	errNoRef = errors.New("no reference specified")
 )
 
+// RefMsg is a message that contains a git.Reference.
+type RefMsg *ggit.Reference
+
 // RefItemsMsg is a message that contains a list of RefItem.
 type RefItemsMsg struct {
 	prefix string
@@ -29,9 +34,9 @@ type RefItemsMsg struct {
 type Refs struct {
 	common    common.Common
 	selector  *selector.Selector
-	repo      git.GitRepo
-	ref       *ggit.Reference
-	activeRef *ggit.Reference
+	repo      backend.Repository
+	ref       *git.Reference
+	activeRef *git.Reference
 	refPrefix string
 }
 
@@ -104,8 +109,7 @@ func (r *Refs) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case RepoMsg:
 		r.selector.Select(0)
-		r.repo = git.GitRepo(msg)
-		cmds = append(cmds, r.Init())
+		r.repo = msg
 	case RefMsg:
 		r.ref = msg
 		cmds = append(cmds, r.Init())
@@ -136,6 +140,9 @@ func (r *Refs) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		case key.Matches(msg, r.common.KeyMap.SelectItem):
 			cmds = append(cmds, r.selector.SelectItem)
 		}
+	case EmptyRepoMsg:
+		r.ref = nil
+		cmds = append(cmds, r.setItems([]selector.IdentifiableItem{}))
 	}
 	m, cmd := r.selector.Update(msg)
 	r.selector = m.(*selector.Selector)
@@ -169,8 +176,13 @@ func (r *Refs) StatusBarInfo() string {
 
 func (r *Refs) updateItemsCmd() tea.Msg {
 	its := make(RefItems, 0)
-	refs, err := r.repo.References()
+	rr, err := r.repo.Repository()
+	if err != nil {
+		return common.ErrorMsg(err)
+	}
+	refs, err := rr.References()
 	if err != nil {
+		log.Printf("ui: error getting references: %v", err)
 		return common.ErrorMsg(err)
 	}
 	for _, ref := range refs {
@@ -189,8 +201,37 @@ func (r *Refs) updateItemsCmd() tea.Msg {
 	}
 }
 
+func (r *Refs) setItems(items []selector.IdentifiableItem) tea.Cmd {
+	return func() tea.Msg {
+		return RefItemsMsg{
+			items:  items,
+			prefix: r.refPrefix,
+		}
+	}
+}
+
 func switchRefCmd(ref *ggit.Reference) tea.Cmd {
 	return func() tea.Msg {
 		return RefMsg(ref)
 	}
 }
+
+// UpdateRefCmd gets the repository's HEAD reference and sends a RefMsg.
+func UpdateRefCmd(repo backend.Repository) tea.Cmd {
+	return func() tea.Msg {
+		r, err := repo.Repository()
+		if err != nil {
+			return common.ErrorMsg(err)
+		}
+		ref, err := r.HEAD()
+		if err != nil {
+			if bs, err := r.Branches(); err != nil && len(bs) == 0 {
+				return EmptyRepoMsg{}
+			}
+			log.Printf("ui: error getting HEAD reference: %v", err)
+			return common.ErrorMsg(err)
+		}
+		log.Printf("HEAD: %s", ref.Name())
+		return RefMsg(ref)
+	}
+}

ui/pages/repo/repo.go πŸ”—

@@ -9,20 +9,19 @@ import (
 	"github.com/charmbracelet/bubbles/spinner"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
-	"github.com/charmbracelet/soft-serve/config"
-	ggit "github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/server/backend"
 	"github.com/charmbracelet/soft-serve/ui/common"
 	"github.com/charmbracelet/soft-serve/ui/components/footer"
 	"github.com/charmbracelet/soft-serve/ui/components/statusbar"
 	"github.com/charmbracelet/soft-serve/ui/components/tabs"
-	"github.com/charmbracelet/soft-serve/ui/git"
 )
 
 type state int
 
 const (
 	loadingState state = iota
-	loadedState
+	readyState
 )
 
 type tab int
@@ -46,6 +45,9 @@ func (t tab) String() string {
 	}[t]
 }
 
+// EmptyRepoMsg is a message to indicate that the repository is empty.
+type EmptyRepoMsg struct{}
+
 // CopyURLMsg is a message to copy the URL of the current repository.
 type CopyURLMsg struct{}
 
@@ -56,10 +58,7 @@ type ResetURLMsg struct{}
 type UpdateStatusBarMsg struct{}
 
 // RepoMsg is a message that contains a git.Repository.
-type RepoMsg git.GitRepo
-
-// RefMsg is a message that contains a git.Reference.
-type RefMsg *ggit.Reference
+type RepoMsg backend.Repository
 
 // BackMsg is a message to go back to the previous view.
 type BackMsg struct{}
@@ -67,18 +66,20 @@ type BackMsg struct{}
 // Repo is a view for a git repository.
 type Repo struct {
 	common       common.Common
-	cfg          *config.Config
-	selectedRepo git.GitRepo
+	selectedRepo backend.Repository
 	activeTab    tab
 	tabs         *tabs.Tabs
 	statusbar    *statusbar.StatusBar
 	panes        []common.Component
-	ref          *ggit.Reference
+	ref          *git.Reference
 	copyURL      time.Time
+	state        state
+	spinner      spinner.Model
+	panesReady   [lastTab]bool
 }
 
 // New returns a new Repo.
-func New(cfg *config.Config, c common.Common) *Repo {
+func New(c common.Common) *Repo {
 	sb := statusbar.New(c)
 	ts := make([]string, lastTab)
 	// Tabs must match the order of tab constants above.
@@ -89,8 +90,8 @@ func New(cfg *config.Config, c common.Common) *Repo {
 	readme := NewReadme(c)
 	log := NewLog(c)
 	files := NewFiles(c)
-	branches := NewRefs(c, ggit.RefsHeads)
-	tags := NewRefs(c, ggit.RefsTags)
+	branches := NewRefs(c, git.RefsHeads)
+	tags := NewRefs(c, git.RefsTags)
 	// Make sure the order matches the order of tab constants above.
 	panes := []common.Component{
 		readme,
@@ -99,12 +100,15 @@ func New(cfg *config.Config, c common.Common) *Repo {
 		branches,
 		tags,
 	}
+	s := spinner.New(spinner.WithSpinner(spinner.Dot),
+		spinner.WithStyle(c.Styles.Spinner))
 	r := &Repo{
-		cfg:       cfg,
 		common:    c,
 		tabs:      tb,
 		statusbar: sb,
 		panes:     panes,
+		state:     loadingState,
+		spinner:   s,
 	}
 	return r
 }
@@ -162,16 +166,20 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	cmds := make([]tea.Cmd, 0)
 	switch msg := msg.(type) {
 	case RepoMsg:
+		// Set the state to loading when we get a new repository.
+		r.state = loadingState
+		r.panesReady = [lastTab]bool{}
 		r.activeTab = 0
-		r.selectedRepo = git.GitRepo(msg)
+		r.selectedRepo = msg
 		cmds = append(cmds,
 			r.tabs.Init(),
-			r.updateRefCmd,
+			// This will set the selected repo in each pane's model.
 			r.updateModels(msg),
 		)
 	case RefMsg:
 		r.ref = msg
 		for _, p := range r.panes {
+			// Init will initiate each pane's model with its contents.
 			cmds = append(cmds, p.Init())
 		}
 		cmds = append(cmds,
@@ -200,7 +208,7 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 		if r.selectedRepo != nil {
 			cmds = append(cmds, r.updateStatusBarCmd)
-			urlID := fmt.Sprintf("%s-url", r.selectedRepo.Repo())
+			urlID := fmt.Sprintf("%s-url", r.selectedRepo.Name())
 			if msg, ok := msg.(tea.MouseMsg); ok && r.common.Zone.Get(urlID).InBounds(msg) {
 				cmds = append(cmds, r.copyURLCmd())
 			}
@@ -221,46 +229,46 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 		}
 	case CopyURLMsg:
-		r.common.Copy.Copy(
-			git.RepoURL(r.cfg.Host, r.cfg.Port, r.selectedRepo.Repo()),
-		)
+		if cfg := r.common.Config(); cfg != nil {
+			host := cfg.Backend.ServerHost()
+			port := cfg.Backend.ServerPort()
+			r.common.Copy.Copy(
+				common.RepoURL(host, port, r.selectedRepo.Name()),
+			)
+		}
 	case ResetURLMsg:
 		r.copyURL = time.Time{}
-	case ReadmeMsg:
-	case FileItemsMsg:
-		f, cmd := r.panes[filesTab].Update(msg)
-		r.panes[filesTab] = f.(*Files)
-		if cmd != nil {
-			cmds = append(cmds, cmd)
-		}
-	// The Log bubble is the only bubble that uses a spinner, so this is fine
-	// for now. We need to pass the TickMsg to the Log bubble when the Log is
-	// loading but not the current selected tab so that the spinner works.
-	case LogCountMsg, LogItemsMsg, spinner.TickMsg:
-		l, cmd := r.panes[commitsTab].Update(msg)
-		r.panes[commitsTab] = l.(*Log)
-		if cmd != nil {
-			cmds = append(cmds, cmd)
-		}
-	case RefItemsMsg:
-		switch msg.prefix {
-		case ggit.RefsHeads:
-			b, cmd := r.panes[branchesTab].Update(msg)
-			r.panes[branchesTab] = b.(*Refs)
-			if cmd != nil {
-				cmds = append(cmds, cmd)
-			}
-		case ggit.RefsTags:
-			t, cmd := r.panes[tagsTab].Update(msg)
-			r.panes[tagsTab] = t.(*Refs)
-			if cmd != nil {
-				cmds = append(cmds, cmd)
+	case ReadmeMsg, FileItemsMsg, LogCountMsg, LogItemsMsg, RefItemsMsg:
+		cmds = append(cmds, r.updateRepo(msg))
+	// We have two spinners, one is used to when loading the repository and the
+	// other is used when loading the log.
+	// Check if the spinner ID matches the spinner model.
+	case spinner.TickMsg:
+		switch msg.ID {
+		case r.spinner.ID():
+			if r.state == loadingState {
+				s, cmd := r.spinner.Update(msg)
+				r.spinner = s
+				if cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 			}
+		default:
+			cmds = append(cmds, r.updateRepo(msg))
 		}
 	case UpdateStatusBarMsg:
 		cmds = append(cmds, r.updateStatusBarCmd)
 	case tea.WindowSizeMsg:
 		cmds = append(cmds, r.updateModels(msg))
+	case EmptyRepoMsg:
+		r.ref = nil
+		r.state = readyState
+		cmds = append(cmds,
+			r.updateModels(msg),
+			r.updateStatusBarCmd,
+		)
+	case common.ErrorMsg:
+		r.state = readyState
 	}
 	s, cmd := r.statusbar.Update(msg)
 	r.statusbar = s.(*statusbar.StatusBar)
@@ -289,15 +297,24 @@ func (r *Repo) View() string {
 		r.common.Styles.Tabs.GetVerticalFrameSize()
 	mainStyle := repoBodyStyle.
 		Height(r.common.Height - hm)
-	main := r.common.Zone.Mark(
+	var main string
+	var statusbar string
+	switch r.state {
+	case loadingState:
+		main = fmt.Sprintf("%s loading…", r.spinner.View())
+	case readyState:
+		main = r.panes[r.activeTab].View()
+		statusbar = r.statusbar.View()
+	}
+	main = r.common.Zone.Mark(
 		"repo-main",
-		mainStyle.Render(r.panes[r.activeTab].View()),
+		mainStyle.Render(main),
 	)
 	view := lipgloss.JoinVertical(lipgloss.Top,
 		r.headerView(),
 		r.tabs.View(),
 		main,
-		r.statusbar.View(),
+		statusbar,
 	)
 	return s.Render(view)
 }
@@ -306,7 +323,6 @@ func (r *Repo) headerView() string {
 	if r.selectedRepo == nil {
 		return ""
 	}
-	cfg := r.cfg
 	truncate := lipgloss.NewStyle().MaxWidth(r.common.Width)
 	name := r.common.Styles.Repo.HeaderName.Render(r.selectedRepo.Name())
 	desc := r.selectedRepo.Description()
@@ -319,13 +335,16 @@ func (r *Repo) headerView() string {
 	urlStyle := r.common.Styles.URLStyle.Copy().
 		Width(r.common.Width - lipgloss.Width(desc) - 1).
 		Align(lipgloss.Right)
-	url := git.RepoURL(cfg.Host, cfg.Port, r.selectedRepo.Repo())
+	var url string
+	if cfg := r.common.Config(); cfg != nil {
+		url = common.RepoURL(cfg.Backend.ServerHost(), cfg.Backend.ServerPort(), r.selectedRepo.Name())
+	}
 	if !r.copyURL.IsZero() && r.copyURL.Add(time.Second).After(time.Now()) {
 		url = "copied!"
 	}
 	url = common.TruncateString(url, r.common.Width-lipgloss.Width(desc)-1)
 	url = r.common.Zone.Mark(
-		fmt.Sprintf("%s-url", r.selectedRepo.Repo()),
+		fmt.Sprintf("%s-url", r.selectedRepo.Name()),
 		urlStyle.Render(url),
 	)
 	style := r.common.Styles.Repo.Header.Copy().Width(r.common.Width)
@@ -346,27 +365,16 @@ func (r *Repo) updateStatusBarCmd() tea.Msg {
 	}
 	value := r.panes[r.activeTab].(statusbar.Model).StatusBarValue()
 	info := r.panes[r.activeTab].(statusbar.Model).StatusBarInfo()
-	ref := ""
+	branch := "*"
 	if r.ref != nil {
-		ref = r.ref.Name().Short()
+		branch += " " + r.ref.Name().Short()
 	}
 	return statusbar.StatusBarMsg{
-		Key:    r.selectedRepo.Repo(),
+		Key:    r.selectedRepo.Name(),
 		Value:  value,
 		Info:   info,
-		Branch: fmt.Sprintf("* %s", ref),
-	}
-}
-
-func (r *Repo) updateRefCmd() tea.Msg {
-	if r.selectedRepo == nil {
-		return nil
-	}
-	head, err := r.selectedRepo.HEAD()
-	if err != nil {
-		return common.ErrorMsg(err)
+		Branch: branch,
 	}
-	return RefMsg(head)
 }
 
 func (r *Repo) updateModels(msg tea.Msg) tea.Cmd {
@@ -381,6 +389,67 @@ func (r *Repo) updateModels(msg tea.Msg) tea.Cmd {
 	return tea.Batch(cmds...)
 }
 
+func (r *Repo) updateRepo(msg tea.Msg) tea.Cmd {
+	cmds := make([]tea.Cmd, 0)
+	switch msg := msg.(type) {
+	case LogCountMsg, LogItemsMsg, spinner.TickMsg:
+		switch msg.(type) {
+		case LogItemsMsg:
+			r.panesReady[commitsTab] = true
+		}
+		l, cmd := r.panes[commitsTab].Update(msg)
+		r.panes[commitsTab] = l.(*Log)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case FileItemsMsg:
+		r.panesReady[filesTab] = true
+		f, cmd := r.panes[filesTab].Update(msg)
+		r.panes[filesTab] = f.(*Files)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case RefItemsMsg:
+		switch msg.prefix {
+		case git.RefsHeads:
+			r.panesReady[branchesTab] = true
+			b, cmd := r.panes[branchesTab].Update(msg)
+			r.panes[branchesTab] = b.(*Refs)
+			if cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		case git.RefsTags:
+			r.panesReady[tagsTab] = true
+			t, cmd := r.panes[tagsTab].Update(msg)
+			r.panes[tagsTab] = t.(*Refs)
+			if cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
+	case ReadmeMsg:
+		r.panesReady[readmeTab] = true
+	}
+	if r.isReady() {
+		r.state = readyState
+	}
+	return tea.Batch(cmds...)
+}
+
+func (r *Repo) isReady() bool {
+	ready := true
+	// We purposely ignore the log pane here because it has its own spinner.
+	for _, b := range []bool{
+		r.panesReady[filesTab], r.panesReady[branchesTab],
+		r.panesReady[tagsTab], r.panesReady[readmeTab],
+	} {
+		if !b {
+			ready = false
+			break
+		}
+	}
+	return ready
+}
+
 func (r *Repo) copyURLCmd() tea.Cmd {
 	r.copyURL = time.Now()
 	return tea.Batch(

ui/pages/selection/item.go πŸ”—

@@ -3,6 +3,7 @@ package selection
 import (
 	"fmt"
 	"io"
+	"sort"
 	"strings"
 	"time"
 
@@ -10,26 +11,76 @@ import (
 	"github.com/charmbracelet/bubbles/list"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
+	"github.com/charmbracelet/soft-serve/server/backend"
+	"github.com/charmbracelet/soft-serve/server/config"
 	"github.com/charmbracelet/soft-serve/ui/common"
-	"github.com/charmbracelet/soft-serve/ui/git"
 	"github.com/dustin/go-humanize"
 )
 
+var _ sort.Interface = Items{}
+
+// Items is a list of Item.
+type Items []Item
+
+// Len implements sort.Interface.
+func (it Items) Len() int {
+	return len(it)
+}
+
+// Less implements sort.Interface.
+func (it Items) Less(i int, j int) bool {
+	if it[i].lastUpdate == nil && it[j].lastUpdate != nil {
+		return false
+	}
+	if it[i].lastUpdate != nil && it[j].lastUpdate == nil {
+		return true
+	}
+	if it[i].lastUpdate == nil && it[j].lastUpdate == nil {
+		return it[i].repo.Name() < it[j].repo.Name()
+	}
+	return it[i].lastUpdate.After(*it[j].lastUpdate)
+}
+
+// Swap implements sort.Interface.
+func (it Items) Swap(i int, j int) {
+	it[i], it[j] = it[j], it[i]
+}
+
 // Item represents a single item in the selector.
 type Item struct {
-	repo       git.GitRepo
-	lastUpdate time.Time
+	repo       backend.Repository
+	lastUpdate *time.Time
 	cmd        string
 	copied     time.Time
 }
 
+// New creates a new Item.
+func NewItem(repo backend.Repository, cfg *config.Config) (Item, error) {
+	r, err := repo.Repository()
+	if err != nil {
+		return Item{}, err
+	}
+	var lastUpdate *time.Time
+	lu, err := r.LatestCommitTime()
+	if err == nil {
+		lastUpdate = &lu
+	}
+	return Item{
+		repo:       repo,
+		lastUpdate: lastUpdate,
+		cmd:        common.RepoURL(cfg.Backend.ServerHost(), cfg.Backend.ServerPort(), repo.Name()),
+	}, nil
+}
+
 // ID implements selector.IdentifiableItem.
 func (i Item) ID() string {
-	return i.repo.Repo()
+	return i.repo.Name()
 }
 
 // Title returns the item title. Implements list.DefaultItem.
-func (i Item) Title() string { return i.repo.Name() }
+func (i Item) Title() string {
+	return i.repo.Name()
+}
 
 // Description returns the item description. Implements list.DefaultItem.
 func (i Item) Description() string { return i.repo.Description() }
@@ -107,7 +158,10 @@ func (d ItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
 	if isSelected {
 		title += " "
 	}
-	updatedStr := fmt.Sprintf(" Updated %s", humanize.Time(i.lastUpdate))
+	var updatedStr string
+	if i.lastUpdate != nil {
+		updatedStr = fmt.Sprintf(" Updated %s", humanize.Time(*i.lastUpdate))
+	}
 	if m.Width()-styles.Base.GetHorizontalFrameSize()-lipgloss.Width(updatedStr)-lipgloss.Width(title) <= 0 {
 		updatedStr = ""
 	}

ui/pages/selection/selection.go πŸ”—

@@ -2,20 +2,17 @@ package selection
 
 import (
 	"fmt"
-	"strings"
+	"log"
+	"sort"
 
 	"github.com/charmbracelet/bubbles/key"
 	"github.com/charmbracelet/bubbles/list"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
-	"github.com/charmbracelet/soft-serve/config"
 	"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"
 )
 
 type pane int
@@ -35,8 +32,6 @@ func (p pane) String() string {
 
 // Selection is the model for the selection screen/page.
 type Selection struct {
-	cfg          *config.Config
-	pk           ssh.PublicKey
 	common       common.Common
 	readme       *code.Code
 	readmeHeight int
@@ -46,29 +41,27 @@ type Selection struct {
 }
 
 // New creates a new selection model.
-func New(cfg *config.Config, pk ssh.PublicKey, common common.Common) *Selection {
+func New(c common.Common) *Selection {
 	ts := make([]string, lastPane)
 	for i, b := range []pane{selectorPane, readmePane} {
 		ts[i] = b.String()
 	}
-	t := tabs.New(common, ts)
+	t := tabs.New(c, ts)
 	t.TabSeparator = lipgloss.NewStyle()
-	t.TabInactive = common.Styles.TopLevelNormalTab.Copy()
-	t.TabActive = common.Styles.TopLevelActiveTab.Copy()
-	t.TabDot = common.Styles.TopLevelActiveTabDot.Copy()
+	t.TabInactive = c.Styles.TopLevelNormalTab.Copy()
+	t.TabActive = c.Styles.TopLevelActiveTab.Copy()
+	t.TabDot = c.Styles.TopLevelActiveTabDot.Copy()
 	t.UseDot = true
 	sel := &Selection{
-		cfg:        cfg,
-		pk:         pk,
-		common:     common,
+		common:     c,
 		activePane: selectorPane, // start with the selector focused
 		tabs:       t,
 	}
-	readme := code.New(common, "", "")
-	readme.NoContentStyle = readme.NoContentStyle.SetString("No readme found.")
-	selector := selector.New(common,
+	readme := code.New(c, "", "")
+	readme.NoContentStyle = c.Styles.NoContent.Copy().SetString("No readme found.")
+	selector := selector.New(c,
 		[]selector.IdentifiableItem{},
-		ItemDelegate{&common, &sel.activePane})
+		ItemDelegate{&c, &sel.activePane})
 	selector.SetShowTitle(false)
 	selector.SetShowHelp(false)
 	selector.SetShowStatusBar(false)
@@ -184,59 +177,29 @@ func (s *Selection) FullHelp() [][]key.Binding {
 // Init implements tea.Model.
 func (s *Selection) Init() tea.Cmd {
 	var readmeCmd tea.Cmd
-	items := make([]selector.IdentifiableItem, 0)
-	cfg := s.cfg
-	pk := s.pk
+	cfg := s.common.Config()
+	pk := s.common.PublicKey()
+	if cfg == nil || pk == nil {
+		return nil
+	}
+	repos, err := cfg.Backend.Repositories()
+	if err != nil {
+		return common.ErrorCmd(err)
+	}
+	sortedItems := make(Items, 0)
 	// Put configured repos first
-	for _, r := range cfg.Repos {
-		acc := cfg.AuthRepo(r.Repo, pk)
-		if r.Private && acc < wgit.ReadOnlyAccess {
-			continue
-		}
-		repo, err := cfg.Source.GetRepo(r.Repo)
+	for _, r := range repos {
+		item, err := NewItem(r, cfg)
 		if err != nil {
+			log.Printf("ui: failed to create item for %s: %v", r.Name(), err)
 			continue
 		}
-		items = append(items, Item{
-			repo: repo,
-			cmd:  git.RepoURL(cfg.Host, cfg.Port, r.Repo),
-		})
+		sortedItems = append(sortedItems, item)
 	}
-	for _, r := range cfg.Source.AllRepos() {
-		if r.Repo() == "config" {
-			rm, rp := r.Readme()
-			s.readmeHeight = strings.Count(rm, "\n")
-			readmeCmd = s.readme.SetContent(rm, rp)
-		}
-		acc := cfg.AuthRepo(r.Repo(), pk)
-		if r.IsPrivate() && acc < wgit.ReadOnlyAccess {
-			continue
-		}
-		exists := false
-		lc, err := r.Commit("HEAD")
-		if err != nil {
-			return common.ErrorCmd(err)
-		}
-		lastUpdate := lc.Committer.When
-		if lastUpdate.IsZero() {
-			lastUpdate = lc.Author.When
-		}
-		for i, item := range items {
-			item := item.(Item)
-			if item.repo.Repo() == r.Repo() {
-				exists = true
-				item.lastUpdate = lastUpdate
-				items[i] = item
-				break
-			}
-		}
-		if !exists {
-			items = append(items, Item{
-				repo:       r,
-				lastUpdate: lastUpdate,
-				cmd:        git.RepoURL(cfg.Host, cfg.Port, r.Name()),
-			})
-		}
+	sort.Sort(sortedItems)
+	items := make([]selector.IdentifiableItem, len(sortedItems))
+	for i, it := range sortedItems {
+		items[i] = it
 	}
 	return tea.Batch(
 		s.selector.Init(),

ui/styles/styles.go πŸ”—

@@ -119,12 +119,14 @@ type Styles struct {
 		Selector    lipgloss.Style
 		FileContent lipgloss.Style
 		Paginator   lipgloss.Style
-		NoItems     lipgloss.Style
 	}
 
-	Spinner lipgloss.Style
+	Spinner          lipgloss.Style
+	SpinnerContainer lipgloss.Style
 
-	CodeNoContent lipgloss.Style
+	NoContent lipgloss.Style
+
+	NoItems lipgloss.Style
 
 	StatusBar       lipgloss.Style
 	StatusBarKey    lipgloss.Style
@@ -402,19 +404,21 @@ func DefaultStyles() *Styles {
 
 	s.Tree.Paginator = s.Log.Paginator.Copy()
 
-	s.Tree.NoItems = s.AboutNoReadme.Copy()
-
 	s.Spinner = lipgloss.NewStyle().
 		MarginTop(1).
 		MarginLeft(2).
 		Foreground(lipgloss.Color("205"))
 
-	s.CodeNoContent = lipgloss.NewStyle().
+	s.SpinnerContainer = lipgloss.NewStyle()
+
+	s.NoContent = lipgloss.NewStyle().
 		SetString("No Content.").
-		MarginTop(1).
+		MarginTop(2).
 		MarginLeft(2).
 		Foreground(lipgloss.Color("242"))
 
+	s.NoItems = s.AboutNoReadme.Copy()
+
 	s.StatusBar = lipgloss.NewStyle().
 		Height(1)
 

ui/ui.go πŸ”—

@@ -1,19 +1,20 @@
 package ui
 
 import (
+	"errors"
+	"log"
+
 	"github.com/charmbracelet/bubbles/key"
 	"github.com/charmbracelet/bubbles/list"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
-	"github.com/charmbracelet/soft-serve/config"
+	"github.com/charmbracelet/soft-serve/server/backend"
 	"github.com/charmbracelet/soft-serve/ui/common"
 	"github.com/charmbracelet/soft-serve/ui/components/footer"
 	"github.com/charmbracelet/soft-serve/ui/components/header"
 	"github.com/charmbracelet/soft-serve/ui/components/selector"
-	"github.com/charmbracelet/soft-serve/ui/git"
 	"github.com/charmbracelet/soft-serve/ui/pages/repo"
 	"github.com/charmbracelet/soft-serve/ui/pages/selection"
-	"github.com/gliderlabs/ssh"
 )
 
 type page int
@@ -26,16 +27,14 @@ const (
 type sessionState int
 
 const (
-	startState sessionState = iota
+	loadingState sessionState = iota
 	errorState
-	loadedState
+	readyState
 )
 
 // UI is the main UI model.
 type UI struct {
-	cfg         *config.Config
-	session     ssh.Session
-	rs          git.GitRepoSource
+	serverName  string
 	initialRepo string
 	common      common.Common
 	pages       []common.Component
@@ -48,17 +47,18 @@ type UI struct {
 }
 
 // New returns a new UI model.
-func New(cfg *config.Config, s ssh.Session, c common.Common, initialRepo string) *UI {
-	src := &source{cfg.Source}
-	h := header.New(c, cfg.Name)
+func New(c common.Common, initialRepo string) *UI {
+	var serverName string
+	if cfg := c.Config(); cfg != nil {
+		serverName = cfg.Backend.ServerName()
+	}
+	h := header.New(c, serverName)
 	ui := &UI{
-		cfg:         cfg,
-		session:     s,
-		rs:          src,
+		serverName:  serverName,
 		common:      c,
 		pages:       make([]common.Component, 2), // selection & repo
 		activePage:  selectionPage,
-		state:       startState,
+		state:       loadingState,
 		header:      h,
 		initialRepo: initialRepo,
 		showFooter:  true,
@@ -92,7 +92,7 @@ func (ui *UI) ShortHelp() []key.Binding {
 	switch ui.state {
 	case errorState:
 		b = append(b, ui.common.KeyMap.Back)
-	case loadedState:
+	case readyState:
 		b = append(b, ui.pages[ui.activePage].ShortHelp()...)
 	}
 	if !ui.IsFiltering() {
@@ -108,7 +108,7 @@ func (ui *UI) FullHelp() [][]key.Binding {
 	switch ui.state {
 	case errorState:
 		b = append(b, []key.Binding{ui.common.KeyMap.Back})
-	case loadedState:
+	case readyState:
 		b = append(b, ui.pages[ui.activePage].FullHelp()...)
 	}
 	h := []key.Binding{
@@ -136,15 +136,8 @@ func (ui *UI) SetSize(width, height int) {
 
 // Init implements tea.Model.
 func (ui *UI) Init() tea.Cmd {
-	ui.pages[selectionPage] = selection.New(
-		ui.cfg,
-		ui.session.PublicKey(),
-		ui.common,
-	)
-	ui.pages[repoPage] = repo.New(
-		ui.cfg,
-		ui.common,
-	)
+	ui.pages[selectionPage] = selection.New(ui.common)
+	ui.pages[repoPage] = repo.New(ui.common)
 	ui.SetSize(ui.common.Width, ui.common.Height)
 	cmds := make([]tea.Cmd, 0)
 	cmds = append(cmds,
@@ -154,7 +147,7 @@ func (ui *UI) Init() tea.Cmd {
 	if ui.initialRepo != "" {
 		cmds = append(cmds, ui.initialRepoCmd(ui.initialRepo))
 	}
-	ui.state = loadedState
+	ui.state = readyState
 	ui.SetSize(ui.common.Width, ui.common.Height)
 	return tea.Batch(cmds...)
 }
@@ -171,6 +164,7 @@ func (ui *UI) IsFiltering() bool {
 
 // Update implements tea.Model.
 func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	log.Printf("msg received: %T", msg)
 	cmds := make([]tea.Cmd, 0)
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
@@ -188,7 +182,7 @@ func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			switch {
 			case key.Matches(msg, ui.common.KeyMap.Back) && ui.error != nil:
 				ui.error = nil
-				ui.state = loadedState
+				ui.state = readyState
 				// Always show the footer on error.
 				ui.showFooter = ui.footer.ShowAll()
 			case key.Matches(msg, ui.common.KeyMap.Help):
@@ -220,14 +214,15 @@ func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			ui.showFooter = !ui.showFooter
 		}
 	case repo.RepoMsg:
+		ui.common.SetValue(common.RepoKey, msg)
 		ui.activePage = repoPage
 		// Show the footer on repo page if show all is set.
 		ui.showFooter = ui.footer.ShowAll()
+		cmds = append(cmds, repo.UpdateRefCmd(msg))
 	case common.ErrorMsg:
 		ui.error = msg
 		ui.state = errorState
 		ui.showFooter = true
-		return ui, nil
 	case selector.SelectMsg:
 		switch msg.IdentifiableItem.(type) {
 		case selection.Item:
@@ -246,7 +241,7 @@ func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	if cmd != nil {
 		cmds = append(cmds, cmd)
 	}
-	if ui.state == loadedState {
+	if ui.state != loadingState {
 		m, cmd := ui.pages[ui.activePage].Update(msg)
 		ui.pages[ui.activePage] = m.(common.Component)
 		if cmd != nil {
@@ -263,7 +258,7 @@ func (ui *UI) View() string {
 	var view string
 	wm, hm := ui.getMargins()
 	switch ui.state {
-	case startState:
+	case loadingState:
 		view = "Loading..."
 	case errorState:
 		err := ui.common.Styles.ErrorTitle.Render("Bummer")
@@ -276,7 +271,7 @@ func (ui *UI) View() string {
 				hm -
 				ui.common.Styles.Error.GetVerticalFrameSize()).
 			Render(err)
-	case loadedState:
+	case readyState:
 		view = ui.pages[ui.activePage].View()
 	default:
 		view = "Unknown state :/ this is a bug!"
@@ -292,24 +287,40 @@ func (ui *UI) View() string {
 	)
 }
 
+func (ui *UI) openRepo(rn string) (backend.Repository, error) {
+	cfg := ui.common.Config()
+	if cfg == nil {
+		return nil, errors.New("config is nil")
+	}
+	repos, err := cfg.Backend.Repositories()
+	if err != nil {
+		log.Printf("ui: failed to list repos: %v", err)
+		return nil, err
+	}
+	for _, r := range repos {
+		if r.Name() == rn {
+			return r, nil
+		}
+	}
+	return nil, common.ErrMissingRepo
+}
+
 func (ui *UI) setRepoCmd(rn string) tea.Cmd {
 	return func() tea.Msg {
-		for _, r := range ui.rs.AllRepos() {
-			if r.Repo() == rn {
-				return repo.RepoMsg(r)
-			}
+		r, err := ui.openRepo(rn)
+		if err != nil {
+			return common.ErrorMsg(err)
 		}
-		return common.ErrorMsg(git.ErrMissingRepo)
+		return repo.RepoMsg(r)
 	}
 }
 
 func (ui *UI) initialRepoCmd(rn string) tea.Cmd {
 	return func() tea.Msg {
-		for _, r := range ui.rs.AllRepos() {
-			if r.Repo() == rn {
-				return repo.RepoMsg(r)
-			}
+		r, err := ui.openRepo(rn)
+		if err != nil {
+			return nil
 		}
-		return nil
+		return repo.RepoMsg(r)
 	}
 }