feat(server): use db for storage

Ayman Bagabas created

Change summary

config/auth.go                   |  38 +-
config/auth_test.go              |  76 ++--
config/config.go                 |   6 
git/config.go                    |  44 +++
git/repo.go                      |  28 ++
go.mod                           |  16 +
go.sum                           |  81 ++++++
proto/access.go                  |  34 ++
proto/provider.go                |   7 
proto/repo.go                    |  17 +
server/cmd/cat.go                |   4 
server/cmd/git.go                |   4 
server/cmd/list.go               |   6 
server/cmd/reload.go             |   4 
server/db/db.go                  |  74 +++++
server/db/sqlite/repo.go         |  58 ++++
server/db/sqlite/sql.go          | 107 ++++++++
server/db/sqlite/sqlite.go       | 452 ++++++++++++++++++++++++++++++++++
server/db/types/config.go        |  15 +
server/db/types/publickey.go     |  64 ++++
server/db/types/repo.go          |  19 +
server/db/types/user.go          |  34 ++
server/file/file.go              | 108 ++++++++
server/git/auth.go               |  38 --
server/git/daemon/daemon.go      |   3 
server/git/daemon/daemon_test.go |   9 
server/git/ssh/ssh.go            |   5 
server/git/ssh/ssh_test.go       |  22 
server/server_test.go            |  30 +
server/session.go                |   4 
server/session_test.go           |   5 
ui/pages/selection/selection.go  |   6 
32 files changed, 1,279 insertions(+), 139 deletions(-)

Detailed changes

config/auth.go 🔗

@@ -4,7 +4,7 @@ import (
 	"log"
 	"strings"
 
-	gm "github.com/charmbracelet/soft-serve/server/git"
+	"github.com/charmbracelet/soft-serve/proto"
 	"github.com/gliderlabs/ssh"
 	gossh "golang.org/x/crypto/ssh"
 )
@@ -39,40 +39,40 @@ func (cfg *Config) Fetch(repo string, pk ssh.PublicKey) {
 }
 
 // AuthRepo grants repo authorization to the given key.
-func (cfg *Config) AuthRepo(repo string, pk ssh.PublicKey) gm.AccessLevel {
+func (cfg *Config) AuthRepo(repo string, pk ssh.PublicKey) proto.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 != gm.NoAccess.String()) && cfg.AllowKeyless
+	return (cfg.AnonAccess != proto.NoAccess.String()) && cfg.AllowKeyless
 }
 
 // KeyboardInteractiveHandler returns whether or not keyboard interactive is allowed.
 func (cfg *Config) KeyboardInteractiveHandler(ctx ssh.Context, _ gossh.KeyboardInteractiveChallenge) bool {
-	return (cfg.AnonAccess != gm.NoAccess.String()) && cfg.AllowKeyless
+	return (cfg.AnonAccess != proto.NoAccess.String()) && 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
+	return cfg.accessForKey("", pk) != proto.NoAccess
 }
 
-func (cfg *Config) anonAccessLevel() gm.AccessLevel {
+func (cfg *Config) anonAccessLevel() proto.AccessLevel {
 	cfg.mtx.RLock()
 	defer cfg.mtx.RUnlock()
 	switch cfg.AnonAccess {
 	case "no-access":
-		return gm.NoAccess
+		return proto.NoAccess
 	case "read-only":
-		return gm.ReadOnlyAccess
+		return proto.ReadOnlyAccess
 	case "read-write":
-		return gm.ReadWriteAccess
+		return proto.ReadWriteAccess
 	case "admin-access":
-		return gm.AdminAccess
+		return proto.AdminAccess
 	default:
-		return gm.NoAccess
+		return proto.NoAccess
 	}
 }
 
@@ -82,7 +82,7 @@ func (cfg *Config) anonAccessLevel() gm.AccessLevel {
 // 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 {
+func (cfg *Config) accessForKey(repo string, pk ssh.PublicKey) proto.AccessLevel {
 	anon := cfg.anonAccessLevel()
 	private := cfg.isPrivate(repo)
 	// Find user
@@ -92,24 +92,24 @@ func (cfg *Config) accessForKey(repo string, pk ssh.PublicKey) gm.AccessLevel {
 				apk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(strings.TrimSpace(k)))
 				if err != nil {
 					log.Printf("error: malformed authorized key: '%s'", k)
-					return gm.NoAccess
+					return proto.NoAccess
 				}
 				if ssh.KeysEqual(pk, apk) {
 					if user.Admin {
-						return gm.AdminAccess
+						return proto.AdminAccess
 					}
 					u := user
 					if cfg.isCollab(repo, &u) {
-						if anon > gm.ReadWriteAccess {
+						if anon > proto.ReadWriteAccess {
 							return anon
 						}
-						return gm.ReadWriteAccess
+						return proto.ReadWriteAccess
 					}
 					if !private {
-						if anon > gm.ReadOnlyAccess {
+						if anon > proto.ReadOnlyAccess {
 							return anon
 						}
-						return gm.ReadOnlyAccess
+						return proto.ReadOnlyAccess
 					}
 				}
 			}
@@ -118,7 +118,7 @@ func (cfg *Config) accessForKey(repo string, pk ssh.PublicKey) gm.AccessLevel {
 	// 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 proto.NoAccess
 	}
 	return anon
 }

config/auth_test.go 🔗

@@ -3,7 +3,7 @@ package config
 import (
 	"testing"
 
-	"github.com/charmbracelet/soft-serve/server/git"
+	"github.com/charmbracelet/soft-serve/proto"
 	"github.com/gliderlabs/ssh"
 	"github.com/matryer/is"
 )
@@ -18,12 +18,12 @@ func TestAuth(t *testing.T) {
 		cfg    Config
 		repo   string
 		key    ssh.PublicKey
-		access git.AccessLevel
+		access proto.AccessLevel
 	}{
 		// Repo access
 		{
 			name:   "anon access: no-access, anonymous user",
-			access: git.NoAccess,
+			access: proto.NoAccess,
 			repo:   "foo",
 			cfg: Config{
 				AnonAccess: "no-access",
@@ -36,7 +36,7 @@ func TestAuth(t *testing.T) {
 		},
 		{
 			name:   "anon access: no-access, anonymous user with admin user",
-			access: git.NoAccess,
+			access: proto.NoAccess,
 			repo:   "foo",
 			cfg: Config{
 				AnonAccess: "no-access",
@@ -59,7 +59,7 @@ func TestAuth(t *testing.T) {
 			name:   "anon access: no-access, authd user",
 			key:    dummyPk,
 			repo:   "foo",
-			access: git.ReadOnlyAccess,
+			access: proto.ReadOnlyAccess,
 			cfg: Config{
 				AnonAccess: "no-access",
 				Repos: []RepoConfig{
@@ -80,7 +80,7 @@ func TestAuth(t *testing.T) {
 			name:   "anon access: no-access, anonymous user with admin user",
 			key:    dummyPk,
 			repo:   "foo",
-			access: git.NoAccess,
+			access: proto.NoAccess,
 			cfg: Config{
 				AnonAccess: "no-access",
 				Repos: []RepoConfig{
@@ -102,7 +102,7 @@ func TestAuth(t *testing.T) {
 			name:   "anon access: no-access, admin user",
 			repo:   "foo",
 			key:    adminPk,
-			access: git.AdminAccess,
+			access: proto.AdminAccess,
 			cfg: Config{
 				AnonAccess: "no-access",
 				Repos: []RepoConfig{
@@ -123,7 +123,7 @@ func TestAuth(t *testing.T) {
 		{
 			name:   "anon access: read-only, anonymous user",
 			repo:   "foo",
-			access: git.ReadOnlyAccess,
+			access: proto.ReadOnlyAccess,
 			cfg: Config{
 				AnonAccess: "read-only",
 				Repos: []RepoConfig{
@@ -137,7 +137,7 @@ func TestAuth(t *testing.T) {
 			name:   "anon access: read-only, authd user",
 			repo:   "foo",
 			key:    dummyPk,
-			access: git.ReadOnlyAccess,
+			access: proto.ReadOnlyAccess,
 			cfg: Config{
 				AnonAccess: "read-only",
 				Repos: []RepoConfig{
@@ -158,7 +158,7 @@ func TestAuth(t *testing.T) {
 			name:   "anon access: read-only, admin user",
 			repo:   "foo",
 			key:    adminPk,
-			access: git.AdminAccess,
+			access: proto.AdminAccess,
 			cfg: Config{
 				AnonAccess: "read-only",
 				Repos: []RepoConfig{
@@ -179,7 +179,7 @@ func TestAuth(t *testing.T) {
 		{
 			name:   "anon access: read-write, anonymous user",
 			repo:   "foo",
-			access: git.ReadWriteAccess,
+			access: proto.ReadWriteAccess,
 			cfg: Config{
 				AnonAccess: "read-write",
 				Repos: []RepoConfig{
@@ -193,7 +193,7 @@ func TestAuth(t *testing.T) {
 			name:   "anon access: read-write, authd user",
 			repo:   "foo",
 			key:    dummyPk,
-			access: git.ReadWriteAccess,
+			access: proto.ReadWriteAccess,
 			cfg: Config{
 				AnonAccess: "read-write",
 				Repos: []RepoConfig{
@@ -213,7 +213,7 @@ func TestAuth(t *testing.T) {
 			name:   "anon access: read-write, admin user",
 			repo:   "foo",
 			key:    adminPk,
-			access: git.AdminAccess,
+			access: proto.AdminAccess,
 			cfg: Config{
 				AnonAccess: "read-write",
 				Repos: []RepoConfig{
@@ -234,7 +234,7 @@ func TestAuth(t *testing.T) {
 		{
 			name:   "anon access: admin-access, anonymous user",
 			repo:   "foo",
-			access: git.AdminAccess,
+			access: proto.AdminAccess,
 			cfg: Config{
 				AnonAccess: "admin-access",
 				Repos: []RepoConfig{
@@ -248,7 +248,7 @@ func TestAuth(t *testing.T) {
 			name:   "anon access: admin-access, authd user",
 			repo:   "foo",
 			key:    dummyPk,
-			access: git.AdminAccess,
+			access: proto.AdminAccess,
 			cfg: Config{
 				AnonAccess: "admin-access",
 				Repos: []RepoConfig{
@@ -268,7 +268,7 @@ func TestAuth(t *testing.T) {
 			name:   "anon access: admin-access, admin user",
 			repo:   "foo",
 			key:    adminPk,
-			access: git.AdminAccess,
+			access: proto.AdminAccess,
 			cfg: Config{
 				AnonAccess: "admin-access",
 				Repos: []RepoConfig{
@@ -292,7 +292,7 @@ func TestAuth(t *testing.T) {
 			name:   "anon access: no-access, authd user, collab",
 			key:    dummyPk,
 			repo:   "foo",
-			access: git.ReadWriteAccess,
+			access: proto.ReadWriteAccess,
 			cfg: Config{
 				AnonAccess: "no-access",
 				Repos: []RepoConfig{
@@ -317,7 +317,7 @@ func TestAuth(t *testing.T) {
 			name:   "anon access: no-access, authd user, collab, private repo",
 			key:    dummyPk,
 			repo:   "foo",
-			access: git.ReadWriteAccess,
+			access: proto.ReadWriteAccess,
 			cfg: Config{
 				AnonAccess: "no-access",
 				Repos: []RepoConfig{
@@ -343,7 +343,7 @@ func TestAuth(t *testing.T) {
 			name:   "anon access: no-access, admin user, collab, private repo",
 			repo:   "foo",
 			key:    adminPk,
-			access: git.AdminAccess,
+			access: proto.AdminAccess,
 			cfg: Config{
 				AnonAccess: "no-access",
 				Repos: []RepoConfig{
@@ -370,7 +370,7 @@ func TestAuth(t *testing.T) {
 			name:   "anon access: read-only, authd user, collab, private repo",
 			repo:   "foo",
 			key:    dummyPk,
-			access: git.ReadWriteAccess,
+			access: proto.ReadWriteAccess,
 			cfg: Config{
 				AnonAccess: "read-only",
 				Repos: []RepoConfig{
@@ -395,7 +395,7 @@ func TestAuth(t *testing.T) {
 		{
 			name:   "anon access: admin-access, anonymous user, collab",
 			repo:   "foo",
-			access: git.AdminAccess,
+			access: proto.AdminAccess,
 			cfg: Config{
 				AnonAccess: "admin-access",
 				Repos: []RepoConfig{
@@ -412,7 +412,7 @@ func TestAuth(t *testing.T) {
 			name:   "anon access: admin-access, authd user, collab",
 			repo:   "foo",
 			key:    dummyPk,
-			access: git.AdminAccess,
+			access: proto.AdminAccess,
 			cfg: Config{
 				AnonAccess: "admin-access",
 				Repos: []RepoConfig{
@@ -436,7 +436,7 @@ func TestAuth(t *testing.T) {
 			name:   "anon access: admin-access, admin user, collab",
 			repo:   "foo",
 			key:    adminPk,
-			access: git.AdminAccess,
+			access: proto.AdminAccess,
 			cfg: Config{
 				AnonAccess: "admin-access",
 				Repos: []RepoConfig{
@@ -462,7 +462,7 @@ func TestAuth(t *testing.T) {
 		// New repo
 		{
 			name:   "anon access: no-access, anonymous user, new repo",
-			access: git.NoAccess,
+			access: proto.NoAccess,
 			repo:   "foo",
 			cfg: Config{
 				AnonAccess: "no-access",
@@ -472,7 +472,7 @@ func TestAuth(t *testing.T) {
 			name:   "anon access: no-access, authd user, new repo",
 			key:    dummyPk,
 			repo:   "foo",
-			access: git.ReadOnlyAccess,
+			access: proto.ReadOnlyAccess,
 			cfg: Config{
 				AnonAccess: "no-access",
 				Users: []User{
@@ -488,7 +488,7 @@ func TestAuth(t *testing.T) {
 			name:   "anon access: no-access, authd user, new repo, with user",
 			key:    dummyPk,
 			repo:   "foo",
-			access: git.NoAccess,
+			access: proto.NoAccess,
 			cfg: Config{
 				AnonAccess: "no-access",
 				Users: []User{
@@ -504,7 +504,7 @@ func TestAuth(t *testing.T) {
 			name:   "anon access: no-access, admin user, new repo",
 			repo:   "foo",
 			key:    adminPk,
-			access: git.AdminAccess,
+			access: proto.AdminAccess,
 			cfg: Config{
 				AnonAccess: "no-access",
 				Users: []User{
@@ -520,7 +520,7 @@ func TestAuth(t *testing.T) {
 		{
 			name:   "anon access: read-only, anonymous user, new repo",
 			repo:   "foo",
-			access: git.ReadOnlyAccess,
+			access: proto.ReadOnlyAccess,
 			cfg: Config{
 				AnonAccess: "read-only",
 			},
@@ -529,7 +529,7 @@ func TestAuth(t *testing.T) {
 			name:   "anon access: read-only, authd user, new repo",
 			repo:   "foo",
 			key:    dummyPk,
-			access: git.ReadOnlyAccess,
+			access: proto.ReadOnlyAccess,
 			cfg: Config{
 				AnonAccess: "read-only",
 				Users: []User{
@@ -545,7 +545,7 @@ func TestAuth(t *testing.T) {
 			name:   "anon access: read-only, admin user, new repo",
 			repo:   "foo",
 			key:    adminPk,
-			access: git.AdminAccess,
+			access: proto.AdminAccess,
 			cfg: Config{
 				AnonAccess: "read-only",
 				Users: []User{
@@ -561,7 +561,7 @@ func TestAuth(t *testing.T) {
 		{
 			name:   "anon access: read-write, anonymous user, new repo",
 			repo:   "foo",
-			access: git.ReadWriteAccess,
+			access: proto.ReadWriteAccess,
 			cfg: Config{
 				AnonAccess: "read-write",
 			},
@@ -570,7 +570,7 @@ func TestAuth(t *testing.T) {
 			name:   "anon access: read-write, authd user, new repo",
 			repo:   "foo",
 			key:    dummyPk,
-			access: git.ReadWriteAccess,
+			access: proto.ReadWriteAccess,
 			cfg: Config{
 				AnonAccess: "read-write",
 				Users: []User{
@@ -586,7 +586,7 @@ func TestAuth(t *testing.T) {
 			name:   "anon access: read-write, admin user, new repo",
 			repo:   "foo",
 			key:    adminPk,
-			access: git.AdminAccess,
+			access: proto.AdminAccess,
 			cfg: Config{
 				AnonAccess: "read-write",
 				Users: []User{
@@ -602,7 +602,7 @@ func TestAuth(t *testing.T) {
 		{
 			name:   "anon access: admin-access, anonymous user, new repo",
 			repo:   "foo",
-			access: git.AdminAccess,
+			access: proto.AdminAccess,
 			cfg: Config{
 				AnonAccess: "admin-access",
 			},
@@ -611,7 +611,7 @@ func TestAuth(t *testing.T) {
 			name:   "anon access: admin-access, authd user, new repo",
 			repo:   "foo",
 			key:    dummyPk,
-			access: git.AdminAccess,
+			access: proto.AdminAccess,
 			cfg: Config{
 				AnonAccess: "admin-access",
 				Users: []User{
@@ -627,7 +627,7 @@ func TestAuth(t *testing.T) {
 			name:   "anon access: admin-access, admin user, new repo",
 			repo:   "foo",
 			key:    adminPk,
-			access: git.AdminAccess,
+			access: proto.AdminAccess,
 			cfg: Config{
 				AnonAccess: "admin-access",
 				Users: []User{
@@ -645,7 +645,7 @@ func TestAuth(t *testing.T) {
 		{
 			name:   "anon access: read-only, no users",
 			repo:   "foo",
-			access: git.ReadOnlyAccess,
+			access: proto.ReadOnlyAccess,
 			cfg: Config{
 				AnonAccess: "read-only",
 			},
@@ -653,7 +653,7 @@ func TestAuth(t *testing.T) {
 		{
 			name:   "anon access: read-write, no users",
 			repo:   "foo",
-			access: git.ReadWriteAccess,
+			access: proto.ReadWriteAccess,
 			cfg: Config{
 				AnonAccess: "read-write",
 			},

config/config.go 🔗

@@ -19,8 +19,8 @@ import (
 	"os"
 
 	"github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/proto"
 	"github.com/charmbracelet/soft-serve/server/config"
-	gm "github.com/charmbracelet/soft-serve/server/git"
 	"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"
@@ -103,9 +103,9 @@ func NewConfig(cfg *config.Config) (*Config, error) {
 	c.Source = rs
 	// Grant read-write access when no keys are provided.
 	if len(pks) == 0 {
-		anonAccess = gm.ReadWriteAccess.String()
+		anonAccess = proto.ReadWriteAccess.String()
 	} else {
-		anonAccess = gm.ReadOnlyAccess.String()
+		anonAccess = proto.ReadOnlyAccess.String()
 	}
 	if host == "" {
 		displayHost = "localhost"

git/config.go 🔗

@@ -0,0 +1,44 @@
+package git
+
+// ConfigOptions are options for Config.
+type ConfigOptions struct {
+	File string
+	All  bool
+	Add  bool
+}
+
+// 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")
+	}
+	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)
+	}
+	cmd.AddArgs(key, value)
+	_, err := cmd.Run()
+	return err
+}

git/repo.go 🔗

@@ -207,3 +207,31 @@ 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)
+}

go.mod 🔗

@@ -33,6 +33,7 @@ require (
 	github.com/spf13/cobra v1.6.1
 	golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
 	gopkg.in/yaml.v3 v3.0.1
+	modernc.org/sqlite v1.19.5
 )
 
 require (
@@ -47,10 +48,12 @@ require (
 	github.com/dlclark/regexp2 v1.4.0 // indirect
 	github.com/emirpasic/gods v1.12.0 // indirect
 	github.com/go-git/gcfg v1.5.0 // indirect
+	github.com/google/uuid v1.3.0 // indirect
 	github.com/gorilla/css v1.0.0 // indirect
 	github.com/imdario/mergo v0.3.12 // indirect
 	github.com/inconshreveable/mousetrap v1.0.1 // indirect
 	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
+	github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
 	github.com/kevinburke/ssh_config v1.1.0 // indirect
 	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
 	github.com/mattn/go-isatty v0.0.16 // indirect
@@ -64,18 +67,31 @@ 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/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
 	github.com/rivo/uniseg v0.2.0 // indirect
 	github.com/sahilm/fuzzy v0.1.0 // indirect
 	github.com/spf13/pflag v1.0.5 // indirect
 	github.com/xanzy/ssh-agent v0.3.1 // indirect
 	github.com/yuin/goldmark v1.5.2 // indirect
 	github.com/yuin/goldmark-emoji v1.0.1 // indirect
+	golang.org/x/mod v0.3.0 // indirect
 	golang.org/x/net v0.0.0-20221002022538-bcab6841153b // indirect
 	golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64 // indirect
 	golang.org/x/term v0.0.0-20220722155259-a9ba230a4035 // indirect
 	golang.org/x/text v0.3.7 // indirect
+	golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 // indirect
+	golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
 	gopkg.in/warnings.v0 v0.1.2 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
+	lukechampine.com/uint128 v1.2.0 // indirect
+	modernc.org/cc/v3 v3.40.0 // indirect
+	modernc.org/ccgo/v3 v3.16.13 // indirect
+	modernc.org/libc v1.21.5 // indirect
+	modernc.org/mathutil v1.5.0 // indirect
+	modernc.org/memory v1.4.0 // indirect
+	modernc.org/opt v0.1.3 // indirect
+	modernc.org/strutil v1.1.3 // indirect
+	modernc.org/token v1.0.1 // indirect
 )
 
 // see https://github.com/sergi/go-diff/issues/123

go.sum 🔗

@@ -43,6 +43,9 @@ github.com/charmbracelet/lipgloss v0.6.0 h1:1StyZB9vBSOyuZxQUcUwGr17JmojPNm87ini
 github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk=
 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/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY=
+github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic=
+github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
 github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
 github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
 github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
@@ -76,11 +79,18 @@ github.com/gogs/git-module v1.7.1/go.mod h1:Y3rsSqtFZEbn7lp+3gWf42GKIY1eNTtLt7Jr
 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 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
+github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 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/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
+github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
+github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
+github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 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/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
 github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU=
 github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
@@ -89,6 +99,8 @@ github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf
 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/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
+github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
 github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
 github.com/kevinburke/ssh_config v1.1.0 h1:pH/t1WS9NzT8go394IqZeJTMHVm6Cr6ZJ6AQ+mdNo/o=
 github.com/kevinburke/ssh_config v1.1.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
@@ -120,6 +132,8 @@ github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRC
 github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
 github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
+github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
 github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75 h1:Pijfgr7ZuvX7QIQiEwLdRVr3RoMG+i0SbBO1Qu+7yVk=
 github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75/go.mod h1:76rfSfYPWj01Z85hUf/ituArm797mNKcvINh1OlsZKo=
 github.com/microcosm-cc/bluemonday v1.0.21 h1:dNH3e4PSyE4vNX+KlRGHT5KrSvjeUkoNPwEORjffHJg=
@@ -156,6 +170,8 @@ 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=
+github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk=
+github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
 github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -191,26 +207,39 @@ 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/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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 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-20210711020723-a769d52b0f97/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-20220826181053-bd7e27e6170d h1:3qF+Z8Hkrw9sOhrFHti9TlB1Hkac1x+DNRkv0XQiFjo=
 golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
 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-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
 golang.org/x/net v0.0.0-20221002022538-bcab6841153b h1:6e93nYa3hNqAvLr0pD4PN1fFS+gKzp2zAXqrnTCstqU=
 golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
+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-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 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-20200930185726-fdedc70b468f/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=
@@ -222,6 +251,7 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64 h1:UiNENfZ8gDvpiWw7IpOMQ27spWmThO1RwwdQVbJahJM=
@@ -230,13 +260,21 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.0.0-20220722155259-a9ba230a4035 h1:Q5284mrmYTpACcm+eAKjKJH48BBwSyfJqmmGDTtT8Vc=
 golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/time v0.0.0-20220411224347-583f2d630306/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.0.0-20201124115921-2c860bdd6e78 h1:M8tBwCtWD/cZV9DZpFYRUgaymAYAr+aIUTWzDaM3uPs=
+golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/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=
@@ -252,3 +290,44 @@ 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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
+lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
+lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
+modernc.org/cc/v3 v3.37.0/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20=
+modernc.org/cc/v3 v3.38.1/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20=
+modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
+modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
+modernc.org/ccgo/v3 v3.0.0-20220904174949-82d86e1b6d56/go.mod h1:YSXjPL62P2AMSxBphRHPn7IkzhVHqkvOnRKAKh+W6ZI=
+modernc.org/ccgo/v3 v3.0.0-20220910160915-348f15de615a/go.mod h1:8p47QxPkdugex9J4n9P2tLZ9bK01yngIVp00g4nomW0=
+modernc.org/ccgo/v3 v3.16.13-0.20221017192402-261537637ce8/go.mod h1:fUB3Vn0nVPReA+7IG7yZDfjv1TMWjhQP8gCxrFAtL5g=
+modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
+modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
+modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
+modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
+modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
+modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
+modernc.org/libc v1.17.4/go.mod h1:WNg2ZH56rDEwdropAJeZPQkXmDwh+JCA1s/htl6r2fA=
+modernc.org/libc v1.18.0/go.mod h1:vj6zehR5bfc98ipowQOM2nIDUZnVew/wNC/2tOGS+q0=
+modernc.org/libc v1.19.0/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=
+modernc.org/libc v1.20.3/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=
+modernc.org/libc v1.21.4/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI=
+modernc.org/libc v1.21.5 h1:xBkU9fnHV+hvZuPSRszN0AXDG4M7nwPLwTWwkYcvLCI=
+modernc.org/libc v1.21.5/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI=
+modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
+modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
+modernc.org/memory v1.3.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
+modernc.org/memory v1.4.0 h1:crykUfNSnMAXaOJnnxcSzbUGMqkLWjklJKkBK2nwZwk=
+modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
+modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
+modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
+modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
+modernc.org/sqlite v1.19.5 h1:E3iHL55c1Vw1knqIeU9N7B0fSjuiOjHZo7iVMsO6U5U=
+modernc.org/sqlite v1.19.5/go.mod h1:EsYz8rfOvLCiYTy5ZFsOYzoCcRMu98YYkwAcCw5YIYw=
+modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
+modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
+modernc.org/tcl v1.15.0 h1:oY+JeD11qVVSgVvodMJsu7Edf8tr5E/7tuhF5cNYz34=
+modernc.org/tcl v1.15.0/go.mod h1:xRoGotBZ6dU+Zo2tca+2EqVEeMmOUBzHnhIwq4YrVnE=
+modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
+modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+modernc.org/z v1.7.0 h1:xkDw/KepgEjeizO2sNco+hqYkU12taxQFqPEmgm1GWE=
+modernc.org/z v1.7.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ=

proto/access.go 🔗

@@ -0,0 +1,34 @@
+package proto
+
+// AccessLevel is the level of access allowed to a repo.
+type AccessLevel int
+
+const (
+	// NoAccess does not allow access to the repo.
+	NoAccess AccessLevel = iota
+
+	// ReadOnlyAccess allows read-only access to the repo.
+	ReadOnlyAccess
+
+	// ReadWriteAccess allows read and write access to the repo.
+	ReadWriteAccess
+
+	// AdminAccess allows read, write, and admin access to the repo.
+	AdminAccess
+)
+
+// String implements the Stringer interface for AccessLevel.
+func (a AccessLevel) String() string {
+	switch a {
+	case NoAccess:
+		return "no-access"
+	case ReadOnlyAccess:
+		return "read-only"
+	case ReadWriteAccess:
+		return "read-write"
+	case AdminAccess:
+		return "admin-access"
+	default:
+		return ""
+	}
+}

proto/provider.go 🔗

@@ -0,0 +1,7 @@
+package proto
+
+// Provider is a repository provider.
+type Provider interface {
+	// Open opens a repository.
+	Open(name string) (RepositoryService, error)
+}

proto/repo.go 🔗

@@ -0,0 +1,17 @@
+package proto
+
+// Repository is Git repository.
+type Repository interface {
+	Name() string
+	ProjectName() string
+	Description() string
+	IsPrivate() bool
+}
+
+// RepositoryService is a service for managing repositories metadata.
+type RepositoryService interface {
+	Repository
+	SetProjectName(string) error
+	SetDescription(string) error
+	SetPrivate(bool) error
+}

server/cmd/cat.go 🔗

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

server/cmd/git.go 🔗

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

server/cmd/list.go 🔗

@@ -6,7 +6,7 @@ import (
 	"strings"
 
 	"github.com/charmbracelet/soft-serve/git"
-	gm "github.com/charmbracelet/soft-serve/server/git"
+	"github.com/charmbracelet/soft-serve/proto"
 	"github.com/spf13/cobra"
 )
 
@@ -27,13 +27,13 @@ func ListCommand() *cobra.Command {
 				ps = strings.Split(path, "/")
 				rn = ps[0]
 				auth := ac.AuthRepo(rn, s.PublicKey())
-				if auth < gm.ReadOnlyAccess {
+				if auth < proto.ReadOnlyAccess {
 					return ErrUnauthorized
 				}
 			}
 			if path == "" || path == "." || path == "/" {
 				for _, r := range ac.Source.AllRepos() {
-					if ac.AuthRepo(r.Repo(), s.PublicKey()) >= gm.ReadOnlyAccess {
+					if ac.AuthRepo(r.Repo(), s.PublicKey()) >= proto.ReadOnlyAccess {
 						fmt.Fprintln(s, r.Repo())
 					}
 				}

server/cmd/reload.go 🔗

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

server/db/db.go 🔗

@@ -0,0 +1,74 @@
+package db
+
+import (
+	"github.com/charmbracelet/soft-serve/server/db/types"
+)
+
+// ConfigStore is a configuration database storage.
+type ConfigStore interface {
+	// Config
+	GetConfig() (*types.Config, error)
+	SetConfigName(string) error
+	SetConfigHost(string) error
+	SetConfigPort(int) error
+	SetConfigAnonAccess(string) error
+	SetConfigAllowKeyless(bool) error
+}
+
+// UserStore is a user database storage.
+type UserStore interface {
+	// Users
+	AddUser(name, login, email, password string, isAdmin bool) error
+	DeleteUser(int) error
+	GetUser(int) (*types.User, error)
+	GetUserByLogin(string) (*types.User, error)
+	GetUserByEmail(string) (*types.User, error)
+	GetUserByPublicKey(string) (*types.User, error)
+	SetUserName(*types.User, string) error
+	SetUserLogin(*types.User, string) error
+	SetUserEmail(*types.User, string) error
+	SetUserPassword(*types.User, string) error
+	SetUserAdmin(*types.User, bool) error
+}
+
+// PublicKeyStore is a public key database storage.
+type PublicKeyStore interface {
+	// Public keys
+	AddUserPublicKey(*types.User, string) error
+	DeleteUserPublicKey(int) error
+	GetUserPublicKeys(*types.User) ([]*types.PublicKey, error)
+}
+
+// RepoStore is a repository database storage.
+type RepoStore interface {
+	// Repos
+	AddRepo(name, projectName, description string, isPrivate bool) error
+	DeleteRepo(string) error
+	GetRepo(string) (*types.Repo, error)
+	SetRepoProjectName(string, string) error
+	SetRepoDescription(string, string) error
+	SetRepoPrivate(string, bool) error
+}
+
+// CollabStore is a collaborator database storage.
+type CollabStore interface {
+	// Collaborators
+	AddRepoCollab(*types.Repo, *types.User) error
+	DeleteRepoCollab(int, int) error
+	ListRepoCollabs(*types.Repo) ([]*types.User, error)
+}
+
+// DB is a database.
+type DB interface {
+	ConfigStore
+	UserStore
+	PublicKeyStore
+	RepoStore
+	CollabStore
+
+	// CreateDB creates the database.
+	CreateDB() error
+
+	// Close closes the database.
+	Close() error
+}

server/db/sqlite/repo.go 🔗

@@ -0,0 +1,58 @@
+package sqlite
+
+import (
+	"github.com/charmbracelet/soft-serve/proto"
+	"github.com/charmbracelet/soft-serve/server/db/types"
+)
+
+// Open opens a repository.
+func (d *Sqlite) Open(name string) (proto.RepositoryService, error) {
+	r, err := d.GetRepo(name)
+	if err != nil {
+		return nil, err
+	}
+	return &repository{
+		repo: r,
+		db:   d,
+	}, nil
+}
+
+type repository struct {
+	repo *types.Repo
+	db   *Sqlite
+}
+
+// Name returns the repository's name.
+func (r *repository) Name() string {
+	return r.repo.Name
+}
+
+// ProjectName returns the repository's project name.
+func (r *repository) ProjectName() string {
+	return r.repo.ProjectName
+}
+
+// SetProjectName sets the repository's project name.
+func (r *repository) SetProjectName(name string) error {
+	return r.db.SetRepoProjectName(r.repo.Name, name)
+}
+
+// Description returns the repository's description.
+func (r *repository) Description() string {
+	return r.repo.Description
+}
+
+// SetDescription sets the repository's description.
+func (r *repository) SetDescription(desc string) error {
+	return r.db.SetRepoDescription(r.repo.Name, desc)
+}
+
+// IsPrivate returns whether the repository is private.
+func (r *repository) IsPrivate() bool {
+	return r.repo.Private
+}
+
+// SetPrivate sets whether the repository is private.
+func (r *repository) SetPrivate(p bool) error {
+	return r.db.SetRepoPrivate(r.repo.Name, p)
+}

server/db/sqlite/sql.go 🔗

@@ -0,0 +1,107 @@
+package sqlite
+
+var (
+	sqlCreateConfigTable = `CREATE TABLE IF NOT EXISTS config (
+		id INTEGER PRIMARY KEY AUTOINCREMENT,
+		name TEXT NOT NULL,
+		host TEXT NOT NULL,
+		port INTEGER NOT NULL,
+		anon_access TEXT NOT NULL,
+		allow_keyless BOOLEAN NOT NULL,
+		created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+		updated_at DATETIME NOT NULL
+	);
+	`
+
+	sqlCreateUserTable = `CREATE TABLE IF NOT EXISTS user (
+		id INTEGER PRIMARY KEY AUTOINCREMENT,
+		name TEXT NOT NULL,
+		login TEXT UNIQUE,
+		email TEXT UNIQUE,
+		password TEXT,
+		admin BOOLEAN NOT NULL,
+		created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+		updated_at DATETIME NOT NULL
+	);`
+
+	sqlCreatePublicKeyTable = `CREATE TABLE IF NOT EXISTS public_key (
+		id INTEGER PRIMARY KEY AUTOINCREMENT,
+		user_id INTEGER NOT NULL,
+		public_key TEXT NOT NULL,
+		created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+		updated_at DATETIME NOT NULL,
+		UNIQUE (user_id, public_key),
+		CONSTRAINT user_id_fk
+		FOREIGN KEY(user_id) REFERENCES user(id)
+		ON DELETE CASCADE
+		ON UPDATE CASCADE
+	);`
+
+	sqlCreateRepoTable = `CREATE TABLE IF NOT EXISTS repo (
+		id INTEGER PRIMARY KEY AUTOINCREMENT,
+		name TEXT NOT NULL UNIQUE,
+		project_name TEXT NOT NULL,
+		description TEXT NOT NULL,
+		private BOOLEAN NOT NULL,
+		create_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+		updated_at DATETIME NOT NULL,
+	);`
+
+	sqlCreateCollabTable = `CREATE TABLE IF NOT EXISTS collab (
+		id INTEGER PRIMARY KEY AUTOINCREMENT,
+		user_id INTEGER NOT NULL,
+		repo_id INTEGER NOT NULL,
+		created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+		updated_at DATETIME NOT NULL,
+		UNIQUE (user_id, repo_id),
+		CONSTRAINT user_id_fk
+		FOREIGN KEY(user_id) REFERENCES user(id)
+		ON DELETE CASCADE
+		ON UPDATE CASCADE,
+		CONSTRAINT repo_id_fk
+		FOREIGN KEY(repo_id) REFERENCES repo(id)
+		ON DELETE CASCADE
+		ON UPDATE CASCADE
+	);`
+
+	// Config.
+	sqlInsertConfig        = `INSERT INTO config (name, host, port, anon_access, allow_keyless, updated_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP);`
+	sqlSelectConfig        = `SELECT id, name, host, port, anon_access, allow_keyless, created_at, updated_at FROM config WHERE id = ?;`
+	sqlUpdateConfigName    = `UPDATE config SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?;`
+	sqlUpdateConfigHost    = `UPDATE config SET host = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?;`
+	sqlUpdateConfigPort    = `UPDATE config SET port = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?;`
+	sqlUpdateConfigAnon    = `UPDATE config SET anon_access = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?;`
+	sqlUpdateConfigKeyless = `UPDATE config SET allow_keyless = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?;`
+
+	// User.
+	sqlInsertUser            = `INSERT INTO user (name, login, email, password, admin, updated_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP);`
+	sqlDeleteUser            = `DELETE FROM user WHERE id = ?;`
+	sqlSelectUser            = `SELECT id, name, login, email, password, admin, created_at, updated_at FROM user WHERE id = ?;`
+	sqlSelectUserByLogin     = `SELECT id, name, login, email, password, admin, created_at, updated_at FROM user WHERE login = ?;`
+	sqlSelectUserByEmail     = `SELECT id, name, login, email, password, admin, created_at, updated_at FROM user WHERE email = ?;`
+	sqlSelectUserByPublicKey = `SELECT u.id, u.name, u.login, u.email, u.password, u.admin, u.created_at, u.updated_at FROM user u INNER JOIN public_key pk ON u.id = pk.user_id WHERE pk.public_key = ?;`
+	sqlUpdateUserName        = `UPDATE user SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?;`
+	sqlUpdateUserLogin       = `UPDATE user SET login = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?;`
+	sqlUpdateUserEmail       = `UPDATE user SET email = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?;`
+	sqlUpdateUserPassword    = `UPDATE user SET password = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?;`
+	sqlUpdateUserAdmin       = `UPDATE user SET admin = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?;`
+
+	// Public Key.
+	sqlInsertPublicKey      = `INSERT INTO public_key (user_id, public_key, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP);`
+	sqlDeletePublicKey      = `DELETE FROM public_key WHERE id = ?;`
+	sqlSelectUserPublicKeys = `SELECT id, user_id, public_key, created_at, updated_at FROM public_key WHERE user_id = ?;`
+
+	// Repo.
+	sqlInsertRepo                  = `INSERT INTO repo (name, project_name, description, private, updated_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP);`
+	sqlDeleteRepo                  = `DELETE FROM repo WHERE id = ?;`
+	sqlDeleteRepoWithName          = `DELETE FROM repo WHERE name = ?;`
+	sqlSelectRepoByName            = `SELECT id, name, project_name, description, private, created_at, updated_at FROM repo WHERE name = ?;`
+	sqlUpdateRepoProjectNameByName = `UPDATE repo SET project_name = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?;`
+	sqlUpdateRepoDescriptionByName = `UPDATE repo SET description = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?;`
+	sqlUpdateRepoPrivateByName     = `UPDATE repo SET private = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?;`
+
+	// Collab.
+	sqlInsertCollab      = `INSERT INTO collab (user_id, repo_id, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP);`
+	sqlDeleteCollab      = `DELETE FROM collab WHERE user_id = ? AND repo_id;`
+	sqlSelectRepoCollabs = `SELECT user.id, user.name, user.login, user.email, user.admin, user.created_at, user.updated_at FROM user INNER JOIN collab ON user.id = collab.user_id WHERE collab.repo_id = ?;`
+)

server/db/sqlite/sqlite.go 🔗

@@ -0,0 +1,452 @@
+package sqlite
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+	"log"
+	"strings"
+	"time"
+
+	"github.com/charmbracelet/soft-serve/server/db"
+	"github.com/charmbracelet/soft-serve/server/db/types"
+	"modernc.org/sqlite"
+	sqlitelib "modernc.org/sqlite/lib"
+)
+
+var _ db.DB = &Sqlite{}
+
+// Sqlite is a SQLite database.
+type Sqlite struct {
+	path string
+	db   *sql.DB
+}
+
+// New creates a new DB in the given path.
+func New(path string) (*Sqlite, error) {
+	var err error
+	log.Printf("Opening SQLite db: %s\n", path)
+	db, err := sql.Open("sqlite", path+
+		"?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)")
+	if err != nil {
+		return nil, err
+	}
+	d := &Sqlite{
+		db:   db,
+		path: path,
+	}
+	if err = d.CreateDB(); err != nil {
+		return nil, err
+	}
+	return d, d.db.Ping()
+}
+
+// Close closes the database.
+func (d *Sqlite) Close() error {
+	return d.db.Close()
+}
+
+// CreateDB creates the database and tables.
+func (d *Sqlite) CreateDB() error {
+	return d.wrapTransaction(func(tx *sql.Tx) error {
+		if _, err := tx.Exec(sqlInsertConfig); err != nil {
+			return err
+		}
+		if _, err := tx.Exec(sqlCreateUserTable); err != nil {
+			return err
+		}
+		if _, err := tx.Exec(sqlCreatePublicKeyTable); err != nil {
+			return err
+		}
+		if _, err := tx.Exec(sqlCreateRepoTable); err != nil {
+			return err
+		}
+		if _, err := tx.Exec(sqlCreateCollabTable); err != nil {
+			return err
+		}
+		return nil
+	})
+}
+
+const defaultConfigID = 1
+
+// GetConfig returns the server config.
+func (d *Sqlite) GetConfig() (*types.Config, error) {
+	var c types.Config
+	if err := d.wrapTransaction(func(tx *sql.Tx) error {
+		r := tx.QueryRow(sqlSelectConfig, defaultConfigID)
+		if err := r.Scan(&c.ID, &c.Name, &c.Host, &c.Port, &c.AnonAccess, &c.AllowKeyless, &c.CreatedAt, &c.UpdatedAt); err != nil {
+			return err
+		}
+		return nil
+	}); err != nil {
+		return nil, err
+	}
+	return &c, nil
+}
+
+// SetConfigName sets the server config name.
+func (d *Sqlite) SetConfigName(name string) error {
+	return d.wrapTransaction(func(tx *sql.Tx) error {
+		_, err := tx.Exec(sqlUpdateConfigName, name, defaultConfigID)
+		return err
+	})
+}
+
+// SetConfigHost sets the server config host.
+func (d *Sqlite) SetConfigHost(host string) error {
+	return d.wrapTransaction(func(tx *sql.Tx) error {
+		_, err := tx.Exec(sqlUpdateConfigHost, host, defaultConfigID)
+		return err
+	})
+}
+
+// SetConfigPort sets the server config port.
+func (d *Sqlite) SetConfigPort(port int) error {
+	return d.wrapTransaction(func(tx *sql.Tx) error {
+		_, err := tx.Exec(sqlUpdateConfigPort, port, defaultConfigID)
+		return err
+	})
+}
+
+// SetConfigAnonAccess sets the server config anon access.
+func (d *Sqlite) SetConfigAnonAccess(access string) error {
+	return d.wrapTransaction(func(tx *sql.Tx) error {
+		_, err := tx.Exec(sqlUpdateConfigAnon, access, defaultConfigID)
+		return err
+	})
+}
+
+// SetConfigAllowKeyless sets the server config allow keyless.
+func (d *Sqlite) SetConfigAllowKeyless(allow bool) error {
+	return d.wrapTransaction(func(tx *sql.Tx) error {
+		_, err := tx.Exec(sqlUpdateConfigKeyless, allow, defaultConfigID)
+		return err
+	})
+}
+
+// AddUser adds a new user.
+func (d *Sqlite) AddUser(name, login, email, password string, isAdmin bool) error {
+	var l *string
+	var e *string
+	var p *string
+	if login != "" {
+		login = strings.ToLower(login)
+		l = &login
+	}
+	if email != "" {
+		email = strings.ToLower(email)
+		e = &email
+	}
+	if password != "" {
+		p = &password
+	}
+	if err := d.wrapTransaction(func(tx *sql.Tx) error {
+		if _, err := tx.Exec(sqlInsertUser, name, l, e, p, isAdmin); err != nil {
+			return err
+		}
+		return nil
+	}); err != nil {
+		return err
+	}
+	return nil
+}
+
+// DeleteUser deletes a user.
+func (d *Sqlite) DeleteUser(id int) error {
+	return d.wrapTransaction(func(tx *sql.Tx) error {
+		_, err := tx.Exec(sqlDeleteUser, id)
+		return err
+	})
+}
+
+// GetUser returns a user by ID.
+func (d *Sqlite) GetUser(id int) (*types.User, error) {
+	var u types.User
+	if err := d.wrapTransaction(func(tx *sql.Tx) error {
+		r := tx.QueryRow(sqlSelectUser, id)
+		if err := r.Scan(&u.ID, &u.Name, &u.Login, &u.Email, &u.Password, &u.Admin, &u.CreatedAt, &u.UpdatedAt); err != nil {
+			return err
+		}
+		return nil
+	}); err != nil {
+		return nil, err
+	}
+	return &u, nil
+}
+
+// GetUserByLogin returns a user by login.
+func (d *Sqlite) GetUserByLogin(login string) (*types.User, error) {
+	login = strings.ToLower(login)
+	var u types.User
+	if err := d.wrapTransaction(func(tx *sql.Tx) error {
+		r := tx.QueryRow(sqlSelectUserByLogin, login)
+		if err := r.Scan(&u.ID, &u.Name, &u.Login, &u.Email, &u.Password, &u.Admin, &u.CreatedAt, &u.UpdatedAt); err != nil {
+			return err
+		}
+		return nil
+	}); err != nil {
+		return nil, err
+	}
+	return &u, nil
+}
+
+// GetUserByLogin returns a user by login.
+func (d *Sqlite) GetUserByEmail(email string) (*types.User, error) {
+	email = strings.ToLower(email)
+	var u types.User
+	if err := d.wrapTransaction(func(tx *sql.Tx) error {
+		r := tx.QueryRow(sqlSelectUserByEmail, email)
+		if err := r.Scan(&u.ID, &u.Name, &u.Login, &u.Email, &u.Password, &u.Admin, &u.CreatedAt, &u.UpdatedAt); err != nil {
+			return err
+		}
+		return nil
+	}); err != nil {
+		return nil, err
+	}
+	return &u, nil
+}
+
+// GetUserByPublicKey returns a user by public key.
+func (d *Sqlite) GetUserByPublicKey(key string) (*types.User, error) {
+	var u types.User
+	if err := d.wrapTransaction(func(tx *sql.Tx) error {
+		r := tx.QueryRow(sqlSelectUserByPublicKey, key)
+		if err := r.Scan(&u.ID, &u.Name, &u.Login, &u.Email, &u.Password, &u.Admin, &u.CreatedAt, &u.UpdatedAt); err != nil {
+			return err
+		}
+		return nil
+	}); err != nil {
+		return nil, err
+	}
+	return &u, nil
+}
+
+// SetUserName sets the user name.
+func (d *Sqlite) SetUserName(user *types.User, name string) error {
+	return d.wrapTransaction(func(tx *sql.Tx) error {
+		_, err := tx.Exec(sqlUpdateUserName, name, user.ID)
+		return err
+	})
+}
+
+// SetUserLogin sets the user login.
+func (d *Sqlite) SetUserLogin(user *types.User, login string) error {
+	if login == "" {
+		return fmt.Errorf("login cannot be empty")
+	}
+	login = strings.ToLower(login)
+	return d.wrapTransaction(func(tx *sql.Tx) error {
+		_, err := tx.Exec(sqlUpdateUserLogin, login, user.ID)
+		return err
+	})
+}
+
+// SetUserEmail sets the user email.
+func (d *Sqlite) SetUserEmail(user *types.User, email string) error {
+	if email == "" {
+		return fmt.Errorf("email cannot be empty")
+	}
+	email = strings.ToLower(email)
+	return d.wrapTransaction(func(tx *sql.Tx) error {
+		_, err := tx.Exec(sqlUpdateUserEmail, email, user.ID)
+		return err
+	})
+}
+
+// SetUserPassword sets the user password.
+func (d *Sqlite) SetUserPassword(user *types.User, password string) error {
+	if password == "" {
+		return fmt.Errorf("password cannot be empty")
+	}
+	return d.wrapTransaction(func(tx *sql.Tx) error {
+		_, err := tx.Exec(sqlUpdateUserPassword, password, user.ID)
+		return err
+	})
+}
+
+// SetUserAdmin sets the user admin.
+func (d *Sqlite) SetUserAdmin(user *types.User, admin bool) error {
+	return d.wrapTransaction(func(tx *sql.Tx) error {
+		_, err := tx.Exec(sqlUpdateUserAdmin, admin, user.ID)
+		return err
+	})
+}
+
+// AddUserPublicKey adds a new user public key.
+func (d *Sqlite) AddUserPublicKey(user *types.User, key string) error {
+	return d.wrapTransaction(func(tx *sql.Tx) error {
+		_, err := tx.Exec(sqlInsertPublicKey, user.ID, key)
+		return err
+	})
+}
+
+// DeleteUserPublicKey deletes a user public key.
+func (d *Sqlite) DeleteUserPublicKey(id int) error {
+	return d.wrapTransaction(func(tx *sql.Tx) error {
+		_, err := tx.Exec(sqlDeletePublicKey, id)
+		return err
+	})
+}
+
+// GetUserPublicKeys returns the user public keys.
+func (d *Sqlite) GetUserPublicKeys(user *types.User) ([]*types.PublicKey, error) {
+	keys := make([]*types.PublicKey, 0)
+	if err := d.wrapTransaction(func(tx *sql.Tx) error {
+		rows, err := tx.Query(sqlSelectUserPublicKeys, user.ID)
+		if err != nil {
+			return err
+		}
+		if err := rows.Err(); err != nil {
+			return err
+		}
+		defer rows.Close()
+		for rows.Next() {
+			var k types.PublicKey
+			if err := rows.Scan(&k.ID, &k.UserID, &k.PublicKey, &k.CreatedAt, &k.UpdatedAt); err != nil {
+				return err
+			}
+			keys = append(keys, &k)
+		}
+		return nil
+	}); err != nil {
+		return nil, err
+	}
+	return keys, nil
+}
+
+// AddRepo adds a new repo.
+func (d *Sqlite) AddRepo(name, projectName, description string, isPrivate bool) error {
+	name = strings.ToLower(name)
+	return d.wrapTransaction(func(tx *sql.Tx) error {
+		_, err := tx.Exec(sqlInsertRepo, name, projectName, description, isPrivate)
+		return err
+	})
+}
+
+// DeleteRepo deletes a repo.
+func (d *Sqlite) DeleteRepo(name string) error {
+	name = strings.ToLower(name)
+	return d.wrapTransaction(func(tx *sql.Tx) error {
+		_, err := tx.Exec(sqlDeleteRepoWithName, name)
+		return err
+	})
+}
+
+// GetRepo returns a repo by name.
+func (d *Sqlite) GetRepo(name string) (*types.Repo, error) {
+	name = strings.ToLower(name)
+	var r types.Repo
+	if err := d.wrapTransaction(func(tx *sql.Tx) error {
+		rows := tx.QueryRow(sqlSelectRepoByName, name)
+		if err := rows.Scan(&r.ID, &r.Name, &r.ProjectName, &r.Description, &r.Private, &r.CreatedAt, &r.UpdatedAt); err != nil {
+			return err
+		}
+		if err := rows.Err(); err != nil {
+			return err
+		}
+		return nil
+	}); err != nil {
+		return nil, err
+	}
+	return &r, nil
+}
+
+// SetRepoProjectName sets the repo project name.
+func (d *Sqlite) SetRepoProjectName(name string, projectName string) error {
+	name = strings.ToLower(name)
+	return d.wrapTransaction(func(tx *sql.Tx) error {
+		_, err := tx.Exec(sqlUpdateRepoProjectNameByName, projectName, name)
+		return err
+	})
+}
+
+// SetRepoDescription sets the repo description.
+func (d *Sqlite) SetRepoDescription(name string, description string) error {
+	name = strings.ToLower(name)
+	return d.wrapTransaction(func(tx *sql.Tx) error {
+		_, err := tx.Exec(sqlUpdateRepoDescriptionByName, description,
+			name)
+		return err
+	})
+}
+
+// SetRepoPrivate sets the repo private.
+func (d *Sqlite) SetRepoPrivate(name string, private bool) error {
+	name = strings.ToLower(name)
+	return d.wrapTransaction(func(tx *sql.Tx) error {
+		_, err := tx.Exec(sqlUpdateRepoPrivateByName, private, name)
+		return err
+	})
+}
+
+// AddRepoCollab adds a new repo collaborator.
+func (d *Sqlite) AddRepoCollab(repo *types.Repo, user *types.User) error {
+	return d.wrapTransaction(func(tx *sql.Tx) error {
+		_, err := tx.Exec(sqlInsertCollab, repo.ID, user.ID)
+		return err
+	})
+}
+
+// DeleteRepoCollab deletes a repo collaborator.
+func (d *Sqlite) DeleteRepoCollab(userID int, repoID int) error {
+	return d.wrapTransaction(func(tx *sql.Tx) error {
+		_, err := tx.Exec(sqlDeleteCollab, repoID, userID)
+		return err
+	})
+}
+
+// ListRepoCollabs returns a list of repo collaborators.
+func (d *Sqlite) ListRepoCollabs(repo *types.Repo) ([]*types.User, error) {
+	collabs := make([]*types.User, 0)
+	if err := d.wrapTransaction(func(tx *sql.Tx) error {
+		rows, err := tx.Query(sqlSelectRepoCollabs, repo.ID)
+		if err != nil {
+			return err
+		}
+		if err := rows.Err(); err != nil {
+			return err
+		}
+		defer rows.Close()
+		for rows.Next() {
+			var c types.User
+			if err := rows.Scan(&c.ID, &c.Name, &c.Login, &c.Email, &c.Admin, &c.CreatedAt, &c.UpdatedAt); err != nil {
+				return err
+			}
+			collabs = append(collabs, &c)
+		}
+		return nil
+	}); err != nil {
+		return nil, err
+	}
+	return collabs, nil
+}
+
+// WrapTransaction runs the given function within a transaction.
+func (d *Sqlite) wrapTransaction(f func(tx *sql.Tx) error) error {
+	ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
+	defer cancel()
+	tx, err := d.db.BeginTx(ctx, nil)
+	if err != nil {
+		log.Printf("error starting transaction: %s", err)
+		return err
+	}
+	for {
+		err = f(tx)
+		if err != nil {
+			serr, ok := err.(*sqlite.Error)
+			if ok && serr.Code() == sqlitelib.SQLITE_BUSY {
+				continue
+			}
+			log.Printf("error in transaction: %s", err)
+			return err
+		}
+		err = tx.Commit()
+		if err != nil {
+			log.Printf("error committing transaction: %s", err)
+			return err
+		}
+		break
+	}
+	return nil
+}

server/db/types/config.go 🔗

@@ -0,0 +1,15 @@
+package types
+
+import "time"
+
+// Config is the Soft Serve application configuration.
+type Config struct {
+	ID           int
+	Name         string
+	Host         string
+	Port         int
+	AnonAccess   string
+	AllowKeyless bool
+	CreatedAt    *time.Time
+	UpdatedAt    *time.Time
+}

server/db/types/publickey.go 🔗

@@ -0,0 +1,64 @@
+package types
+
+import (
+	"fmt"
+	"strings"
+	"time"
+
+	"golang.org/x/crypto/ssh"
+)
+
+var _ ssh.PublicKey = &PublicKey{}
+var _ fmt.Stringer = &PublicKey{}
+
+// PublicKey is a public key database model.
+type PublicKey struct {
+	ID        int
+	UserID    int
+	PublicKey string
+	CreatedAt *time.Time
+	UpdatedAt *time.Time
+}
+
+func (k *PublicKey) publicKey() ssh.PublicKey {
+	pk, err := ssh.ParsePublicKey([]byte(k.PublicKey))
+	if err != nil {
+		return nil
+	}
+	return pk
+}
+
+func (k *PublicKey) String() string {
+	pk := k.publicKey()
+	if pk == nil {
+		return ""
+	}
+	return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pk)))
+}
+
+// Type returns the type of the public key.
+func (k *PublicKey) Type() string {
+	pk := k.publicKey()
+	if pk == nil {
+		return ""
+	}
+	return pk.Type()
+}
+
+// Marshal returns the serialized form of the public key.
+func (k *PublicKey) Marshal() []byte {
+	pk := k.publicKey()
+	if pk == nil {
+		return nil
+	}
+	return pk.Marshal()
+}
+
+// Verify verifies the signature of the given data.
+func (k *PublicKey) Verify(data []byte, sig *ssh.Signature) error {
+	pk := k.publicKey()
+	if pk == nil {
+		return fmt.Errorf("invalid public key")
+	}
+	return pk.Verify(data, sig)
+}

server/db/types/repo.go 🔗

@@ -0,0 +1,19 @@
+package types
+
+import "time"
+
+// Repo is a repository database model.
+type Repo struct {
+	ID          int
+	Name        string
+	ProjectName string
+	Description string
+	Private     bool
+	CreatedAt   *time.Time
+	UpdatedAt   *time.Time
+}
+
+// String returns the name of the repository.
+func (r *Repo) String() string {
+	return r.Name
+}

server/db/types/user.go 🔗

@@ -0,0 +1,34 @@
+package types
+
+import (
+	"net/mail"
+	"time"
+)
+
+// User is a user database model.
+type User struct {
+	ID        int
+	Name      string
+	Login     *string
+	Email     *string
+	Password  *string
+	Admin     bool
+	CreatedAt *time.Time
+	UpdatedAt *time.Time
+}
+
+// Address returns the email address of the user.
+func (u *User) Address() *mail.Address {
+	if u.Email == nil {
+		return nil
+	}
+	return &mail.Address{
+		Name:    u.Name,
+		Address: *u.Email,
+	}
+}
+
+// String returns the name of the user.
+func (u *User) String() string {
+	return u.Name
+}

server/file/file.go 🔗

@@ -0,0 +1,108 @@
+package file
+
+import (
+	"errors"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+
+	"github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/proto"
+)
+
+type key int
+
+const (
+	projectName key = iota
+	description
+	private
+)
+
+var keys = map[key]string{
+	projectName: "soft-serve.projectName",
+	description: "soft-serve.description",
+	private:     "soft-serve.private",
+}
+
+var _ proto.Provider = &File{}
+
+// File is a file-based repository provider.
+type File struct {
+	repoPath string
+}
+
+// New returns a new File provider.
+func New(repoPath string) *File {
+	f := &File{
+		repoPath: repoPath,
+	}
+	return f
+}
+
+// Open opens a new repository and returns a new FileRepo.
+func (f *File) Open(name string) (proto.RepositoryService, error) {
+	fp := filepath.Join(f.repoPath, name)
+	r, err := git.Open(fp)
+	if errors.Is(err, os.ErrNotExist) {
+		r, err = git.Open(fp + ".git")
+	}
+	if err != nil {
+		return nil, err
+	}
+	return &FileRepo{r}, nil
+}
+
+var _ proto.Repository = &FileRepo{}
+
+// FileRepo is a file-based repository.
+type FileRepo struct { // nolint:revive
+	repo *git.Repository
+}
+
+// Name returns the name of the repository.
+func (r *FileRepo) Name() string {
+	return strings.TrimSuffix(r.repo.Name(), ".git")
+}
+
+// ProjectName returns the project name of the repository.
+func (r *FileRepo) ProjectName() string {
+	pn, err := r.repo.Config(keys[projectName])
+	if err != nil {
+		return ""
+	}
+	return pn
+}
+
+// SetProjectName sets the project name of the repository.
+func (r *FileRepo) SetProjectName(name string) error {
+	return r.repo.SetConfig(keys[projectName], name)
+}
+
+// Description returns the description of the repository.
+func (r *FileRepo) Description() string {
+	desc, err := r.repo.Config(keys[description])
+	if err != nil {
+		return ""
+	}
+	return desc
+}
+
+// SetDescription sets the description of the repository.
+func (r *FileRepo) SetDescription(desc string) error {
+	return r.repo.SetConfig(keys[description], desc)
+}
+
+// IsPrivate returns whether the repository is private.
+func (r *FileRepo) IsPrivate() bool {
+	p, err := r.repo.Config(keys[private])
+	if err != nil {
+		return false
+	}
+	return p == "true"
+}
+
+// SetPrivate sets whether the repository is private.
+func (r *FileRepo) SetPrivate(p bool) error {
+	return r.repo.SetConfig(keys[private], strconv.FormatBool(p))
+}

server/git/auth.go 🔗

@@ -1,46 +1,16 @@
 package git
 
-import "github.com/gliderlabs/ssh"
-
-// AccessLevel is the level of access allowed to a repo.
-type AccessLevel int
-
-const (
-	// NoAccess does not allow access to the repo.
-	NoAccess AccessLevel = iota
-
-	// ReadOnlyAccess allows read-only access to the repo.
-	ReadOnlyAccess
-
-	// ReadWriteAccess allows read and write access to the repo.
-	ReadWriteAccess
-
-	// AdminAccess allows read, write, and admin access to the repo.
-	AdminAccess
+import (
+	"github.com/charmbracelet/soft-serve/proto"
+	"github.com/gliderlabs/ssh"
 )
 
-// String implements the Stringer interface for AccessLevel.
-func (a AccessLevel) String() string {
-	switch a {
-	case NoAccess:
-		return "no-access"
-	case ReadOnlyAccess:
-		return "read-only"
-	case ReadWriteAccess:
-		return "read-write"
-	case AdminAccess:
-		return "admin-access"
-	default:
-		return ""
-	}
-}
-
 // Hooks is an interface that allows for custom authorization
 // implementations and post push/fetch notifications. Prior to git access,
 // AuthRepo will be called with the ssh.Session public key and the repo name.
 // Implementers return the appropriate AccessLevel.
 type Hooks interface {
-	AuthRepo(string, ssh.PublicKey) AccessLevel
+	AuthRepo(string, ssh.PublicKey) proto.AccessLevel
 	Push(string, ssh.PublicKey)
 	Fetch(string, ssh.PublicKey)
 }

server/git/daemon/daemon.go 🔗

@@ -12,6 +12,7 @@ import (
 	"sync"
 	"time"
 
+	"github.com/charmbracelet/soft-serve/proto"
 	"github.com/charmbracelet/soft-serve/server/config"
 	"github.com/charmbracelet/soft-serve/server/git"
 	"github.com/go-git/go-git/v5/plumbing/format/pktline"
@@ -159,7 +160,7 @@ func (d *Daemon) handleClient(c net.Conn) {
 	defer log.Printf("git: disconnect %s %s %s", c.RemoteAddr(), cmd, repo)
 	repo = strings.TrimPrefix(repo, "/")
 	auth := d.auth.AuthRepo(strings.TrimSuffix(repo, ".git"), nil)
-	if auth < git.ReadOnlyAccess {
+	if auth < proto.ReadOnlyAccess {
 		fatal(c, git.ErrNotAuthed)
 		return
 	}

server/git/daemon/daemon_test.go 🔗

@@ -17,11 +17,14 @@ import (
 var testDaemon *Daemon
 
 func TestMain(m *testing.M) {
-	testdata := "testdata"
-	defer os.RemoveAll(testdata)
+	tmp, err := os.MkdirTemp("", "soft-serve-test")
+	if err != nil {
+		log.Fatal(err)
+	}
+	defer os.RemoveAll(tmp)
 	cfg := &config.Config{
 		Host:     "",
-		DataPath: testdata,
+		DataPath: tmp,
 		Git: config.GitConfig{
 			// Reduce the max timeout to 100 second so we can test the timeout.
 			MaxTimeout: 100,

server/git/ssh/ssh.go 🔗

@@ -6,6 +6,7 @@ import (
 	"path/filepath"
 	"strings"
 
+	"github.com/charmbracelet/soft-serve/proto"
 	"github.com/charmbracelet/soft-serve/server/git"
 	"github.com/charmbracelet/wish"
 	"github.com/gliderlabs/ssh"
@@ -41,7 +42,7 @@ func Middleware(repoDir string, gh git.Hooks) wish.Middleware {
 					switch gc {
 					case "git-receive-pack":
 						switch access {
-						case git.ReadWriteAccess, git.AdminAccess:
+						case proto.ReadWriteAccess, proto.AdminAccess:
 							err := git.GitPack(s, s, s.Stderr(), gc, repoDir, repo)
 							if err != nil {
 								Fatal(s, git.ErrSystemMalfunction)
@@ -54,7 +55,7 @@ func Middleware(repoDir string, gh git.Hooks) wish.Middleware {
 						return
 					case "git-upload-archive", "git-upload-pack":
 						switch access {
-						case git.ReadOnlyAccess, git.ReadWriteAccess, git.AdminAccess:
+						case proto.ReadOnlyAccess, proto.ReadWriteAccess, proto.AdminAccess:
 							// try to upload <repo>.git first, then <repo>
 							err := git.GitPack(s, s, s.Stderr(), gc, repoDir, repo)
 							if err != nil {

server/git/ssh/ssh_test.go 🔗

@@ -11,7 +11,7 @@ import (
 	"testing"
 
 	"github.com/charmbracelet/keygen"
-	"github.com/charmbracelet/soft-serve/server/git"
+	"github.com/charmbracelet/soft-serve/proto"
 	"github.com/charmbracelet/wish"
 	"github.com/gliderlabs/ssh"
 )
@@ -28,13 +28,13 @@ func TestGitMiddleware(t *testing.T) {
 		pushes:  []action{},
 		fetches: []action{},
 		access: []accessDetails{
-			{pubkey, "repo1", git.AdminAccess},
-			{pubkey, "repo2", git.AdminAccess},
-			{pubkey, "repo3", git.AdminAccess},
-			{pubkey, "repo4", git.AdminAccess},
-			{pubkey, "repo5", git.NoAccess},
-			{pubkey, "repo6", git.ReadOnlyAccess},
-			{pubkey, "repo7", git.AdminAccess},
+			{pubkey, "repo1", proto.AdminAccess},
+			{pubkey, "repo2", proto.AdminAccess},
+			{pubkey, "repo3", proto.AdminAccess},
+			{pubkey, "repo4", proto.AdminAccess},
+			{pubkey, "repo5", proto.NoAccess},
+			{pubkey, "repo6", proto.ReadOnlyAccess},
+			{pubkey, "repo7", proto.AdminAccess},
 		},
 	}
 	srv, err := wish.NewServer(
@@ -180,7 +180,7 @@ func createKeyPair(t *testing.T) (ssh.PublicKey, string) {
 type accessDetails struct {
 	key   ssh.PublicKey
 	repo  string
-	level git.AccessLevel
+	level proto.AccessLevel
 }
 
 type action struct {
@@ -195,13 +195,13 @@ type testHooks struct {
 	access  []accessDetails
 }
 
-func (h *testHooks) AuthRepo(repo string, key ssh.PublicKey) git.AccessLevel {
+func (h *testHooks) AuthRepo(repo string, key ssh.PublicKey) proto.AccessLevel {
 	for _, dets := range h.access {
 		if dets.repo == repo && ssh.KeysEqual(key, dets.key) {
 			return dets.level
 		}
 	}
-	return git.NoAccess
+	return proto.NoAccess
 }
 
 func (h *testHooks) Push(repo string, key ssh.PublicKey) {

server/server_test.go 🔗

@@ -2,6 +2,9 @@ package server
 
 import (
 	"fmt"
+	"log"
+	"net"
+	"os"
 	"path/filepath"
 	"testing"
 
@@ -20,9 +23,7 @@ import (
 var (
 	cfg = &config.Config{
 		Host: "",
-		SSH: config.SSHConfig{
-			Port: 22222,
-		},
+		Git:  config.GitConfig{Port: 9418},
 	}
 )
 
@@ -30,10 +31,8 @@ func TestPushRepo(t *testing.T) {
 	is := is.New(t)
 	_, pkPath := createKeyPair(t)
 	s := setupServer(t)
-	defer s.Close()
 	err := s.Reload()
 	is.NoErr(err)
-
 	rp := t.TempDir()
 	r, err := git.PlainInit(rp, false)
 	is.NoErr(err)
@@ -55,7 +54,7 @@ func TestPushRepo(t *testing.T) {
 	is.NoErr(err)
 	_, err = r.CreateRemote(&gconfig.RemoteConfig{
 		Name: "origin",
-		URLs: []string{fmt.Sprintf("ssh://%s:%d/%s", cfg.Host, cfg.SSH.Port, "testrepo")},
+		URLs: []string{fmt.Sprintf("ssh://localhost:%d/%s", cfg.SSH.Port, "testrepo")},
 	})
 	auth, err := gssh.NewPublicKeysFromFile("git", pkPath, "")
 	is.NoErr(err)
@@ -73,12 +72,13 @@ func TestCloneRepo(t *testing.T) {
 	is := is.New(t)
 	_, pkPath := createKeyPair(t)
 	s := setupServer(t)
-	defer s.Close()
+	log.Print("starting server")
 	err := s.Reload()
+	log.Print("reloaded server")
 	is.NoErr(err)
-
 	dst := t.TempDir()
-	url := fmt.Sprintf("ssh://%s:%d/config", cfg.Host, cfg.SSH.Port)
+	url := fmt.Sprintf("ssh://localhost:%d/config", cfg.SSH.Port)
+	log.Print("cloning repo")
 	err = ggit.Clone(url, dst, ggit.CloneOptions{
 		CommandOptions: ggit.CommandOptions{
 			Envs: []string{
@@ -89,16 +89,24 @@ func TestCloneRepo(t *testing.T) {
 	is.NoErr(err)
 }
 
+func randomPort() int {
+	addr, _ := net.Listen("tcp", ":0") //nolint:gosec
+	_ = addr.Close()
+	return addr.Addr().(*net.TCPAddr).Port
+}
+
 func setupServer(t *testing.T) *Server {
 	t.Helper()
-	tmpdir := t.TempDir()
-	cfg.DataPath = tmpdir
+	cfg.DataPath = t.TempDir()
+	cfg.SSH.Port = randomPort()
 	s := NewServer(cfg)
 	go func() {
+		log.Print("starting server")
 		s.Start()
 	}()
 	t.Cleanup(func() {
 		s.Close()
+		os.RemoveAll(cfg.DataPath)
 	})
 	return s
 }

server/session.go 🔗

@@ -6,8 +6,8 @@ import (
 	"github.com/aymanbagabas/go-osc52"
 	tea "github.com/charmbracelet/bubbletea"
 	appCfg "github.com/charmbracelet/soft-serve/config"
+	"github.com/charmbracelet/soft-serve/proto"
 	cm "github.com/charmbracelet/soft-serve/server/cmd"
-	gm "github.com/charmbracelet/soft-serve/server/git"
 	"github.com/charmbracelet/soft-serve/ui"
 	"github.com/charmbracelet/soft-serve/ui/common"
 	"github.com/charmbracelet/soft-serve/ui/keymap"
@@ -30,7 +30,7 @@ func SessionHandler(ac *appCfg.Config) bm.ProgramHandler {
 		if len(cmd) == 1 {
 			initialRepo = cmd[0]
 			auth := ac.AuthRepo(initialRepo, s.PublicKey())
-			if auth < gm.ReadOnlyAccess {
+			if auth < proto.ReadOnlyAccess {
 				wish.Fatalln(s, cm.ErrUnauthorized)
 				return nil
 			}

server/session_test.go 🔗

@@ -55,9 +55,10 @@ func TestSession(t *testing.T) {
 func setup(tb testing.TB) *gossh.Session {
 	is := is.New(tb)
 	tb.Helper()
-	cfg.DataPath = tb.TempDir()
 	ac, err := appCfg.NewConfig(&config.Config{
-		SSH:      config.SSHConfig{Port: 22226},
+		Host:     "",
+		SSH:      config.SSHConfig{Port: randomPort()},
+		Git:      config.GitConfig{Port: 9418},
 		DataPath: tb.TempDir(),
 		InitialAdminKeys: []string{
 			"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMJlb/qf2B2kMNdBxfpCQqI2ctPcsOkdZGVh5zTRhKtH",

ui/pages/selection/selection.go 🔗

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