7f3e30430a085e0e19ca4724f8e0c9185b3b289a

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

Message

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

* 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

Diff

This diff is truncated to protect this page.

   1diff --git a/.gitignore b/.gitignore
   2index 7f48767177e75ab62894454f61761e963f03f43e..ae70eac83c310247658c36ab11f4e764026d29b1 100644
   3--- a/.gitignore
   4+++ b/.gitignore
   5@@ -1,7 +1,10 @@
   6 cmd/soft/soft
   7+./soft
   8 .ssh
   9 .repos
  10 dist
  11 testdata
  12+data/
  13 completions/
  14-manpages/
  15+manpages/
  16+soft_serve_ed25519*
  17diff --git a/cmd/soft/man.go b/cmd/soft/man.go
  18index ae59fec1bafe3f201c1fe4ec3a0a4a2f3ee298a2..71b1dfb2c3534a3e41d57baf34b5c0fadd09dd59 100644
  19--- a/cmd/soft/man.go
  20+++ b/cmd/soft/man.go
  21@@ -20,7 +20,7 @@ var (
  22 				return err
  23 			}
  24 
  25-			manPage = manPage.WithSection("Copyright", "(C) 2021-2022 Charmbracelet, Inc.\n"+
  26+			manPage = manPage.WithSection("Copyright", "(C) 2021-2023 Charmbracelet, Inc.\n"+
  27 				"Released under MIT license.")
  28 			fmt.Println(manPage.Build(roff.NewDocument()))
  29 			return nil
  30diff --git a/cmd/soft/serve.go b/cmd/soft/serve.go
  31index 5b0b49787f52dee887e2ec794460c78e503d874b..469ba8546db82fdff95896d7a7c36e593c1b7ead 100644
  32--- a/cmd/soft/serve.go
  33+++ b/cmd/soft/serve.go
  34@@ -2,14 +2,12 @@ package main
  35 
  36 import (
  37 	"context"
  38-	"fmt"
  39 	"os"
  40 	"os/signal"
  41 	"syscall"
  42 	"time"
  43 
  44 	"github.com/charmbracelet/log"
  45-
  46 	"github.com/charmbracelet/soft-serve/server"
  47 	"github.com/charmbracelet/soft-serve/server/config"
  48 	"github.com/spf13/cobra"
  49@@ -23,9 +21,14 @@ var (
  50 		Args:  cobra.NoArgs,
  51 		RunE: func(cmd *cobra.Command, args []string) error {
  52 			cfg := config.DefaultConfig()
  53-			s := server.NewServer(cfg)
  54+			s, err := server.NewServer(cfg)
  55+			if err != nil {
  56+				return err
  57+			}
  58 
  59-			log.Print("Starting SSH server", "addr", fmt.Sprintf("%s:%d", cfg.BindAddr, cfg.Port))
  60+			if cfg.Debug {
  61+				log.SetLevel(log.DebugLevel)
  62+			}
  63 
  64 			done := make(chan os.Signal, 1)
  65 			lch := make(chan error, 1)
  66@@ -38,7 +41,6 @@ var (
  67 			signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
  68 			<-done
  69 
  70-			log.Print("Stopping SSH server", "addr", fmt.Sprintf("%s:%d", cfg.BindAddr, cfg.Port))
  71 			ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
  72 			defer cancel()
  73 			if err := s.Shutdown(ctx); err != nil {
  74diff --git a/config/auth.go b/config/auth.go
  75deleted file mode 100644
  76index 84da840f476778ff1c9aaeb9441d308db078d32f..0000000000000000000000000000000000000000
  77--- a/config/auth.go
  78+++ /dev/null
  79@@ -1,155 +0,0 @@
  80-package config
  81-
  82-import (
  83-	"strings"
  84-
  85-	"github.com/charmbracelet/log"
  86-
  87-	gm "github.com/charmbracelet/wish/git"
  88-	"github.com/gliderlabs/ssh"
  89-	gossh "golang.org/x/crypto/ssh"
  90-)
  91-
  92-// Push registers Git push functionality for the given repo and key.
  93-func (cfg *Config) Push(repo string, pk ssh.PublicKey) {
  94-	go func() {
  95-		err := cfg.Reload()
  96-		if err != nil {
  97-			log.Error("error reloading after push", "err", err)
  98-		}
  99-		if cfg.Cfg.Callbacks != nil {
 100-			cfg.Cfg.Callbacks.Push(repo)
 101-		}
 102-		r, err := cfg.Source.GetRepo(repo)
 103-		if err != nil {
 104-			log.Error("error getting repo after push", "err", err)
 105-			return
 106-		}
 107-		err = r.UpdateServerInfo()
 108-		if err != nil {
 109-			log.Error("error updating server info after push", "err", err)
 110-		}
 111-	}()
 112-}
 113-
 114-// Fetch registers Git fetch functionality for the given repo and key.
 115-func (cfg *Config) Fetch(repo string, pk ssh.PublicKey) {
 116-	if cfg.Cfg.Callbacks != nil {
 117-		cfg.Cfg.Callbacks.Fetch(repo)
 118-	}
 119-}
 120-
 121-// AuthRepo grants repo authorization to the given key.
 122-func (cfg *Config) AuthRepo(repo string, pk ssh.PublicKey) gm.AccessLevel {
 123-	return cfg.accessForKey(repo, pk)
 124-}
 125-
 126-// PasswordHandler returns whether or not password access is allowed.
 127-func (cfg *Config) PasswordHandler(ctx ssh.Context, password string) bool {
 128-	return (cfg.AnonAccess != "no-access") && cfg.AllowKeyless
 129-}
 130-
 131-// KeyboardInteractiveHandler returns whether or not keyboard interactive is allowed.
 132-func (cfg *Config) KeyboardInteractiveHandler(ctx ssh.Context, _ gossh.KeyboardInteractiveChallenge) bool {
 133-	return (cfg.AnonAccess != "no-access") && cfg.AllowKeyless
 134-}
 135-
 136-// PublicKeyHandler returns whether or not the given public key may access the
 137-// repo.
 138-func (cfg *Config) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) bool {
 139-	return cfg.accessForKey("", pk) != gm.NoAccess
 140-}
 141-
 142-func (cfg *Config) anonAccessLevel() gm.AccessLevel {
 143-	switch cfg.AnonAccess {
 144-	case "no-access":
 145-		return gm.NoAccess
 146-	case "read-only":
 147-		return gm.ReadOnlyAccess
 148-	case "read-write":
 149-		return gm.ReadWriteAccess
 150-	case "admin-access":
 151-		return gm.AdminAccess
 152-	default:
 153-		return gm.NoAccess
 154-	}
 155-}
 156-
 157-// accessForKey returns the access level for the given repo.
 158-//
 159-// If repo doesn't exist, then access is based on user's admin privileges, or
 160-// config.AnonAccess.
 161-// If repo exists, and private, then admins and collabs are allowed access.
 162-// If repo exists, and not private, then access is based on config.AnonAccess.
 163-func (cfg *Config) accessForKey(repo string, pk ssh.PublicKey) gm.AccessLevel {
 164-	anon := cfg.anonAccessLevel()
 165-	private := cfg.isPrivate(repo)
 166-	// Find user
 167-	for _, user := range cfg.Users {
 168-		for _, k := range user.PublicKeys {
 169-			apk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(strings.TrimSpace(k)))
 170-			if err != nil {
 171-				log.Error("malformed authorized key", "key", k)
 172-				return gm.NoAccess
 173-			}
 174-			if ssh.KeysEqual(pk, apk) {
 175-				if user.Admin {
 176-					return gm.AdminAccess
 177-				}
 178-				u := user
 179diff --git a/config/auth_test.go b/config/auth_test.go
 180deleted file mode 100644
 181index 4f48bafb47cae15a0ba47b17a8a5f6db04c2bce8..0000000000000000000000000000000000000000
 182--- a/config/auth_test.go
 183+++ /dev/null
 184@@ -1,669 +0,0 @@
 185-package config
 186-
 187-import (
 188-	"testing"
 189-
 190-	"github.com/charmbracelet/wish/git"
 191-	"github.com/gliderlabs/ssh"
 192-	"github.com/matryer/is"
 193-)
 194-
 195-func TestAuth(t *testing.T) {
 196-	adminKey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH a@b"
 197-	adminPk, _, _, _, _ := ssh.ParseAuthorizedKey([]byte(adminKey))
 198-	dummyKey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b"
 199-	dummyPk, _, _, _, _ := ssh.ParseAuthorizedKey([]byte(dummyKey))
 200-	cases := []struct {
 201-		name   string
 202-		cfg    Config
 203-		repo   string
 204-		key    ssh.PublicKey
 205-		access git.AccessLevel
 206-	}{
 207-		// Repo access
 208-		{
 209-			name:   "anon access: no-access, anonymous user",
 210-			access: git.NoAccess,
 211-			repo:   "foo",
 212-			cfg: Config{
 213-				AnonAccess: "no-access",
 214-				Repos: []RepoConfig{
 215-					{
 216-						Repo: "foo",
 217-					},
 218-				},
 219-			},
 220-		},
 221-		{
 222-			name:   "anon access: no-access, anonymous user with admin user",
 223-			access: git.NoAccess,
 224-			repo:   "foo",
 225-			cfg: Config{
 226-				AnonAccess: "no-access",
 227-				Repos: []RepoConfig{
 228-					{
 229-						Repo: "foo",
 230-					},
 231-				},
 232-				Users: []User{
 233-					{
 234-						Admin: true,
 235-						PublicKeys: []string{
 236-							adminKey,
 237-						},
 238-					},
 239-				},
 240-			},
 241-		},
 242-		{
 243-			name:   "anon access: no-access, authd user",
 244-			key:    dummyPk,
 245-			repo:   "foo",
 246-			access: git.ReadOnlyAccess,
 247-			cfg: Config{
 248-				AnonAccess: "no-access",
 249-				Repos: []RepoConfig{
 250-					{
 251-						Repo: "foo",
 252-					},
 253-				},
 254-				Users: []User{
 255-					{
 256-						PublicKeys: []string{
 257-							dummyKey,
 258-						},
 259-					},
 260-				},
 261-			},
 262-		},
 263-		{
 264-			name:   "anon access: no-access, anonymous user with admin user",
 265-			key:    dummyPk,
 266-			repo:   "foo",
 267-			access: git.NoAccess,
 268-			cfg: Config{
 269-				AnonAccess: "no-access",
 270-				Repos: []RepoConfig{
 271-					{
 272-						Repo: "foo",
 273-					},
 274-				},
 275-				Users: []User{
 276-					{
 277-						Admin: true,
 278-						PublicKeys: []string{
 279-							adminKey,
 280-						},
 281-					},
 282-				},
 283-			},
 284diff --git a/config/config.go b/config/config.go
 285deleted file mode 100644
 286index 27e083d6caefa0a042654f1ab809af218e86498a..0000000000000000000000000000000000000000
 287--- a/config/config.go
 288+++ /dev/null
 289@@ -1,334 +0,0 @@
 290-package config
 291-
 292-import (
 293-	"bytes"
 294-	"encoding/json"
 295-	"errors"
 296-	"io/fs"
 297-	"path/filepath"
 298-	"strings"
 299-	"sync"
 300-	"text/template"
 301-	"time"
 302-
 303-	"github.com/charmbracelet/log"
 304-
 305-	"golang.org/x/crypto/ssh"
 306-	"gopkg.in/yaml.v3"
 307-
 308-	"fmt"
 309-	"os"
 310-
 311-	"github.com/charmbracelet/soft-serve/git"
 312-	"github.com/charmbracelet/soft-serve/server/config"
 313-	"github.com/go-git/go-billy/v5/memfs"
 314-	ggit "github.com/go-git/go-git/v5"
 315-	"github.com/go-git/go-git/v5/plumbing/object"
 316-	"github.com/go-git/go-git/v5/plumbing/transport"
 317-	"github.com/go-git/go-git/v5/storage/memory"
 318-)
 319-
 320-var (
 321-	// ErrNoConfig is returned when a repo has no config file.
 322-	ErrNoConfig = errors.New("no config file found")
 323-)
 324-
 325-// Config is the Soft Serve configuration.
 326-type Config struct {
 327-	Name         string         `yaml:"name" json:"name"`
 328-	Host         string         `yaml:"host" json:"host"`
 329-	Port         int            `yaml:"port" json:"port"`
 330-	AnonAccess   string         `yaml:"anon-access" json:"anon-access"`
 331-	AllowKeyless bool           `yaml:"allow-keyless" json:"allow-keyless"`
 332-	Users        []User         `yaml:"users" json:"users"`
 333-	Repos        []RepoConfig   `yaml:"repos" json:"repos"`
 334-	Source       *RepoSource    `yaml:"-" json:"-"`
 335-	Cfg          *config.Config `yaml:"-" json:"-"`
 336-	mtx          sync.Mutex
 337-}
 338-
 339-// User contains user-level configuration for a repository.
 340-type User struct {
 341-	Name        string   `yaml:"name" json:"name"`
 342-	Admin       bool     `yaml:"admin" json:"admin"`
 343-	PublicKeys  []string `yaml:"public-keys" json:"public-keys"`
 344-	CollabRepos []string `yaml:"collab-repos" json:"collab-repos"`
 345-}
 346-
 347-// RepoConfig is a repository configuration.
 348-type RepoConfig struct {
 349-	Name    string   `yaml:"name" json:"name"`
 350-	Repo    string   `yaml:"repo" json:"repo"`
 351-	Note    string   `yaml:"note" json:"note"`
 352-	Private bool     `yaml:"private" json:"private"`
 353-	Readme  string   `yaml:"readme" json:"readme"`
 354-	Collabs []string `yaml:"collabs" json:"collabs"`
 355-}
 356-
 357-// NewConfig creates a new internal Config struct.
 358-func NewConfig(cfg *config.Config) (*Config, error) {
 359-	var anonAccess string
 360-	var yamlUsers string
 361-	var displayHost string
 362-	host := cfg.Host
 363-	port := cfg.Port
 364-
 365-	pks := make([]string, 0)
 366-	for _, k := range cfg.InitialAdminKeys {
 367-		if bts, err := os.ReadFile(k); err == nil {
 368-			// pk is a file, set its contents as pk
 369-			k = string(bts)
 370-		}
 371-		var pk = strings.TrimSpace(k)
 372-		if pk == "" {
 373-			continue
 374-		}
 375-		// it is a valid ssh key, nothing to do
 376-		if _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk)); err != nil {
 377-			return nil, fmt.Errorf("invalid initial admin key %q: %w", k, err)
 378-		}
 379-		pks = append(pks, pk)
 380-	}
 381-
 382-	rs := NewRepoSource(cfg.RepoPath)
 383-	c := &Config{
 384-		Cfg: cfg,
 385-	}
 386-	c.Host = cfg.Host
 387-	c.Port = port
 388-	c.Source = rs
 389diff --git a/config/config_test.go b/config/config_test.go
 390deleted file mode 100644
 391index 12ddd8c244a7a556f1498dace6b7da61a1e5d706..0000000000000000000000000000000000000000
 392--- a/config/config_test.go
 393+++ /dev/null
 394@@ -1,37 +0,0 @@
 395-package config
 396-
 397-import (
 398-	"testing"
 399-
 400-	"github.com/charmbracelet/soft-serve/server/config"
 401-	"github.com/matryer/is"
 402-)
 403-
 404-func TestMultipleInitialKeys(t *testing.T) {
 405-	cfg, err := NewConfig(&config.Config{
 406-		RepoPath: t.TempDir(),
 407-		KeyPath:  t.TempDir(),
 408-		InitialAdminKeys: []string{
 409-			"testdata/k1.pub",
 410-			"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b",
 411-		},
 412-	})
 413-	is := is.New(t)
 414-	is.NoErr(err)
 415-	err = cfg.Reload()
 416-	is.NoErr(err)
 417-	is.Equal(cfg.Users[0].PublicKeys, []string{
 418-		"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH a@b",
 419-		"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b",
 420-	}) // should have both keys
 421-}
 422-
 423-func TestEmptyInitialKeys(t *testing.T) {
 424-	cfg, err := NewConfig(&config.Config{
 425-		RepoPath: t.TempDir(),
 426-		KeyPath:  t.TempDir(),
 427-	})
 428-	is := is.New(t)
 429-	is.NoErr(err)
 430-	is.Equal(len(cfg.Users), 0) // should not have any users
 431-}
 432diff --git a/config/defaults.go b/config/defaults.go
 433deleted file mode 100644
 434index a471fab58bdf9a21cf977b8de1f8a6f94e9c215a..0000000000000000000000000000000000000000
 435--- a/config/defaults.go
 436+++ /dev/null
 437@@ -1,58 +0,0 @@
 438-package config
 439-
 440-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```"
 441-
 442-const defaultConfig = `# The name of the server to show in the TUI.
 443-name: Soft Serve
 444-
 445-# The host and port to display in the TUI. You may want to change this if your
 446-# server is accessible from a different host and/or port that what it's
 447-# actually listening on (for example, if it's behind a reverse proxy).
 448-host: %s
 449-port: %d
 450-
 451-# Access level for anonymous users. Options are: admin-access, read-write,
 452-# read-only, and no-access.
 453-anon-access: %s
 454-
 455-# You can grant read-only access to users without private keys. Any password
 456-# will be accepted.
 457-allow-keyless: %t
 458-
 459-# Customize repo display in the menu.
 460-repos:
 461-  - name: Home
 462-    repo: config
 463-    private: true
 464-    note: "Configuration and content repo for this server"
 465-    readme: README.md
 466-`
 467-
 468-const hasKeyUserConfig = `
 469-
 470-# Authorized users. Admins have full access to all repos. Private repos are only
 471-# accessible by admins and collab users. Regular users can read public repos
 472-# based on your anon-access setting.
 473-users:
 474-  - name: Admin
 475-    admin: true
 476-    public-keys:
 477-%s
 478-`
 479-
 480-const defaultUserConfig = `
 481-# users:
 482-#   - name: Admin
 483-#     admin: true
 484-#     public-keys:
 485-#       - ssh-ed25519 AAAA... # redacted
 486-#       - ssh-rsa AAAAB3Nz... # redacted`
 487-
 488-const exampleUserConfig = `
 489-#   - name: Example User
 490-#     collab-repos:
 491-#       - REPO
 492-#     public-keys:
 493-#       - ssh-ed25519 AAAA... # redacted
 494-#       - ssh-rsa AAAAB3Nz... # redacted
 495-`
 496diff --git a/config/git.go b/config/git.go
 497deleted file mode 100644
 498index 9835a4f1d9d8a06ec9f00bc603c7d8685ca2dd94..0000000000000000000000000000000000000000
 499--- a/config/git.go
 500+++ /dev/null
 501@@ -1,304 +0,0 @@
 502-package config
 503-
 504-import (
 505-	"errors"
 506-	"os"
 507-	"path/filepath"
 508-	"sync"
 509-
 510-	"github.com/charmbracelet/log"
 511-
 512-	"github.com/charmbracelet/soft-serve/git"
 513-	"github.com/gobwas/glob"
 514-	"github.com/golang/groupcache/lru"
 515-)
 516-
 517-// ErrMissingRepo indicates that the requested repository could not be found.
 518-var ErrMissingRepo = errors.New("missing repo")
 519-
 520-// Repo represents a Git repository.
 521-type Repo struct {
 522-	name        string
 523-	description string
 524-	path        string
 525-	repository  *git.Repository
 526-	readme      string
 527-	readmePath  string
 528-	head        *git.Reference
 529-	headCommit  string
 530-	refs        []*git.Reference
 531-	patchCache  *lru.Cache
 532-	private     bool
 533-}
 534-
 535-// open opens a Git repository.
 536-func (rs *RepoSource) open(path string) (*Repo, error) {
 537-	rg, err := git.Open(path)
 538-	if err != nil {
 539-		return nil, err
 540-	}
 541-	r := &Repo{
 542-		path:       path,
 543-		repository: rg,
 544-		patchCache: lru.New(1000),
 545-	}
 546-	_, err = r.HEAD()
 547-	if err != nil {
 548-		return nil, err
 549-	}
 550-	_, err = r.References()
 551-	if err != nil {
 552-		return nil, err
 553-	}
 554-	return r, nil
 555-}
 556-
 557-// IsPrivate returns true if the repository is private.
 558-func (r *Repo) IsPrivate() bool {
 559-	return r.private
 560-}
 561-
 562-// Path returns the path to the repository.
 563-func (r *Repo) Path() string {
 564-	return r.path
 565-}
 566-
 567-// Repo returns the repository directory name.
 568-func (r *Repo) Repo() string {
 569-	return filepath.Base(r.path)
 570-}
 571-
 572-// Name returns the name of the repository.
 573-func (r *Repo) Name() string {
 574-	if r.name == "" {
 575-		return r.Repo()
 576-	}
 577-	return r.name
 578-}
 579-
 580-// Description returns the description for a repository.
 581-func (r *Repo) Description() string {
 582-	return r.description
 583-}
 584-
 585-// Readme returns the readme and its path for the repository.
 586-func (r *Repo) Readme() (readme string, path string) {
 587-	return r.readme, r.readmePath
 588-}
 589-
 590-// SetReadme sets the readme for the repository.
 591-func (r *Repo) SetReadme(readme, path string) {
 592-	r.readme = readme
 593-	r.readmePath = path
 594-}
 595-
 596-// HEAD returns the reference for a repository.
 597-func (r *Repo) HEAD() (*git.Reference, error) {
 598-	if r.head != nil {
 599-		return r.head, nil
 600-	}
 601diff --git a/config/testdata/k1.pub b/config/testdata/k1.pub
 602deleted file mode 100644
 603index d82e29394d343e6e36bc1759b06689a399ea80a4..0000000000000000000000000000000000000000
 604--- a/config/testdata/k1.pub
 605+++ /dev/null
 606@@ -1 +0,0 @@
 607-ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH a@b
 608diff --git a/examples/setuid/main.go b/examples/setuid/main.go
 609index 4a722eb9b2aef002a5ce6867252ce08a2a65186d..8999dbcc25fe9f2c379c5d46ddd2f998da524067 100644
 610--- a/examples/setuid/main.go
 611+++ b/examples/setuid/main.go
 612@@ -45,22 +45,25 @@ func main() {
 613 		log.Fatal("Setuid error", "err", err)
 614 	}
 615 	cfg := config.DefaultConfig()
 616-	cfg.Port = *port
 617-	s := server.NewServer(cfg)
 618+	cfg.SSH.ListenAddr = fmt.Sprintf(":%d", *port)
 619+	s, err := server.NewServer(cfg)
 620+	if err != nil {
 621+		log.Fatal(err)
 622+	}
 623 
 624 	done := make(chan os.Signal, 1)
 625 	signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
 626 
 627-	log.Print("Starting SSH server", "addr", fmt.Sprintf("%s:%d", cfg.BindAddr, cfg.Port))
 628+	log.Print("Starting SSH server", "addr", cfg.SSH.ListenAddr)
 629 	go func() {
 630-		if err := s.Serve(ls); err != nil {
 631+		if err := s.SSHServer.Serve(ls); err != nil {
 632 			log.Fatal(err)
 633 		}
 634 	}()
 635 
 636 	<-done
 637 
 638-	log.Print("Stopping SSH server", fmt.Sprintf("%s:%d", cfg.BindAddr, cfg.Port))
 639+	log.Print("Stopping SSH server", "addr", cfg.SSH.ListenAddr)
 640 	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
 641 	defer func() { cancel() }()
 642 	if err := s.Shutdown(ctx); err != nil {
 643diff --git a/git/command.go b/git/command.go
 644new file mode 100644
 645index 0000000000000000000000000000000000000000..eb4f0d17ac5af0c7313cd200244a2bac36c04c13
 646--- /dev/null
 647+++ b/git/command.go
 648@@ -0,0 +1,11 @@
 649+package git
 650+
 651+import "github.com/gogs/git-module"
 652+
 653+// RunInDirOptions are options for RunInDir.
 654+type RunInDirOptions = git.RunInDirOptions
 655+
 656+// NewCommand creates a new git command.
 657+func NewCommand(args ...string) *git.Command {
 658+	return git.NewCommand(args...)
 659+}
 660diff --git a/git/config.go b/git/config.go
 661new file mode 100644
 662index 0000000000000000000000000000000000000000..4e9af6ed500302e8d1e7e7afeabf13bbba4bde48
 663--- /dev/null
 664+++ b/git/config.go
 665@@ -0,0 +1,51 @@
 666+package git
 667+
 668+// ConfigOptions are options for Config.
 669+type ConfigOptions struct {
 670+	File string
 671+	All  bool
 672+	Add  bool
 673+	CommandOptions
 674+}
 675+
 676+// Config gets a git configuration.
 677+func Config(key string, opts ...ConfigOptions) (string, error) {
 678+	var opt ConfigOptions
 679+	if len(opts) > 0 {
 680+		opt = opts[0]
 681+	}
 682+	cmd := NewCommand("config")
 683+	if opt.File != "" {
 684+		cmd.AddArgs("--file", opt.File)
 685+	}
 686+	if opt.All {
 687+		cmd.AddArgs("--get-all")
 688+	}
 689+	for _, a := range opt.Args {
 690+		cmd.AddArgs(a)
 691+	}
 692+	cmd.AddArgs(key)
 693+	bts, err := cmd.Run()
 694+	if err != nil {
 695+		return "", err
 696+	}
 697+	return string(bts), nil
 698+}
 699+
 700+// SetConfig sets a git configuration.
 701+func SetConfig(key string, value string, opts ...ConfigOptions) error {
 702+	var opt ConfigOptions
 703+	if len(opts) > 0 {
 704+		opt = opts[0]
 705+	}
 706+	cmd := NewCommand("config")
 707+	if opt.File != "" {
 708+		cmd.AddArgs("--file", opt.File)
 709+	}
 710+	for _, a := range opt.Args {
 711+		cmd.AddArgs(a)
 712+	}
 713+	cmd.AddArgs(key, value)
 714+	_, err := cmd.Run()
 715+	return err
 716+}
 717diff --git a/git/errors.go b/git/errors.go
 718index e4c2ec35c8774584bdb34c1e0526cd8c4e66339c..40b0d390f3603c5168c9a4318229f8444869be44 100644
 719--- a/git/errors.go
 720+++ b/git/errors.go
 721@@ -11,8 +11,8 @@ var (
 722 	ErrFileNotFound = errors.New("file not found")
 723 	// ErrDirectoryNotFound is returned when a directory is not found.
 724 	ErrDirectoryNotFound = errors.New("directory not found")
 725-	// ErrReferenceNotFound is returned when a reference is not found.
 726-	ErrReferenceNotFound = errors.New("reference not found")
 727+	// ErrReferenceNotExist is returned when a reference does not exist.
 728+	ErrReferenceNotExist = git.ErrReferenceNotExist
 729 	// ErrRevisionNotExist is returned when a revision is not found.
 730 	ErrRevisionNotExist = git.ErrRevisionNotExist
 731 	// ErrNotAGitRepository is returned when the given path is not a Git repository.
 732diff --git a/git/repo.go b/git/repo.go
 733index 9dd674032cdab567f48a64092386e78f5ac327c0..9d1f49d776e763fa2fb30be2c36fcc3f6259305f 100644
 734--- a/git/repo.go
 735+++ b/git/repo.go
 736@@ -79,7 +79,7 @@ func (r *Repository) Name() string {
 737 
 738 // HEAD returns the HEAD reference for a repository.
 739 func (r *Repository) HEAD() (*Reference, error) {
 740-	rn, err := r.SymbolicRef()
 741+	rn, err := r.Repository.SymbolicRef(git.SymbolicRefOptions{Name: "HEAD"})
 742 	if err != nil {
 743 		return nil, err
 744 	}
 745@@ -212,3 +212,41 @@ func (r *Repository) UpdateServerInfo() error {
 746 	_, err := cmd.RunInDir(r.Path)
 747 	return err
 748 }
 749+
 750+// Config returns the config value for the given key.
 751+func (r *Repository) Config(key string, opts ...ConfigOptions) (string, error) {
 752+	dir, err := gitDir(r.Repository)
 753+	if err != nil {
 754+		return "", err
 755+	}
 756+	var opt ConfigOptions
 757+	if len(opts) > 0 {
 758+		opt = opts[0]
 759+	}
 760+	opt.File = filepath.Join(dir, "config")
 761+	return Config(key, opt)
 762+}
 763+
 764+// SetConfig sets the config value for the given key.
 765+func (r *Repository) SetConfig(key, value string, opts ...ConfigOptions) error {
 766+	dir, err := gitDir(r.Repository)
 767+	if err != nil {
 768+		return err
 769+	}
 770+	var opt ConfigOptions
 771+	if len(opts) > 0 {
 772+		opt = opts[0]
 773+	}
 774+	opt.File = filepath.Join(dir, "config")
 775+	return SetConfig(key, value, opt)
 776+}
 777+
 778+// SymbolicRef returns or updates the symbolic reference for the given name.
 779+// Both name and ref can be empty.
 780+func (r *Repository) SymbolicRef(name string, ref string) (string, error) {
 781+	opt := git.SymbolicRefOptions{
 782+		Name: name,
 783+		Ref:  ref,
 784+	}
 785+	return r.Repository.SymbolicRef(opt)
 786+}
 787diff --git a/git/types.go b/git/types.go
 788new file mode 100644
 789index 0000000000000000000000000000000000000000..daf89b03eead9808ef30154605faa288ce4a3e19
 790--- /dev/null
 791+++ b/git/types.go
 792@@ -0,0 +1,9 @@
 793+package git
 794+
 795+import "github.com/gogs/git-module"
 796+
 797+// CommandOptions contain options for running a git command.
 798+type CommandOptions = git.CommandOptions
 799+
 800+// CloneOptions contain options for cloning a repository.
 801+type CloneOptions = git.CloneOptions
 802diff --git a/go.mod b/go.mod
 803index 3deb348a0d872d388d79dea5bb3ad0d3d863f8b8..5f3309903a6dcb74d512f8b67aad64ce0238261e 100644
 804--- a/go.mod
 805+++ b/go.mod
 806@@ -9,10 +9,8 @@ require (
 807 	github.com/charmbracelet/bubbletea v0.23.2
 808 	github.com/charmbracelet/glamour v0.6.0
 809 	github.com/charmbracelet/lipgloss v0.7.1
 810-	github.com/charmbracelet/wish v0.7.0
 811+	github.com/charmbracelet/wish v1.1.0
 812 	github.com/dustin/go-humanize v1.0.1
 813-	github.com/gliderlabs/ssh v0.3.5
 814-	github.com/go-git/go-billy/v5 v5.4.1
 815 	github.com/go-git/go-git/v5 v5.6.1
 816 	github.com/matryer/is v1.4.1
 817 	github.com/muesli/reflow v0.3.0
 818@@ -22,39 +20,30 @@ require (
 819 
 820 require (
 821 	github.com/aymanbagabas/go-osc52 v1.2.2
 822-	github.com/charmbracelet/keygen v0.3.0
 823 	github.com/charmbracelet/log v0.2.1
 824+	github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103
 825 	github.com/gobwas/glob v0.2.3
 826 	github.com/gogs/git-module v1.8.1
 827-	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da
 828 	github.com/lrstanley/bubblezone v0.0.0-20220716194435-3cb8c52f6a8f
 829 	github.com/muesli/mango-cobra v1.2.0
 830 	github.com/muesli/roff v0.1.0
 831 	github.com/spf13/cobra v1.6.1
 832 	golang.org/x/crypto v0.7.0
 833-	gopkg.in/yaml.v3 v3.0.1
 834+	golang.org/x/sync v0.1.0
 835 )
 836 
 837 require (
 838-	github.com/Microsoft/go-winio v0.5.2 // indirect
 839-	github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect
 840-	github.com/acomagu/bufpipe v1.0.4 // indirect
 841 	github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
 842 	github.com/atotto/clipboard v0.1.4 // indirect
 843 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
 844 	github.com/aymerick/douceur v0.2.0 // indirect
 845 	github.com/caarlos0/sshmarshal v0.1.0 // indirect
 846-	github.com/cloudflare/circl v1.1.0 // indirect
 847+	github.com/charmbracelet/keygen v0.3.0 // indirect
 848 	github.com/containerd/console v1.0.3 // indirect
 849 	github.com/dlclark/regexp2 v1.4.0 // indirect
 850-	github.com/emirpasic/gods v1.18.1 // indirect
 851-	github.com/go-git/gcfg v1.5.0 // indirect
 852 	github.com/go-logfmt/logfmt v0.6.0 // indirect
 853 	github.com/gorilla/css v1.0.0 // indirect
 854-	github.com/imdario/mergo v0.3.13 // indirect
 855 	github.com/inconshreveable/mousetrap v1.0.1 // indirect
 856-	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
 857-	github.com/kevinburke/ssh_config v1.2.0 // indirect
 858 	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
 859 	github.com/mattn/go-isatty v0.0.18 // indirect
 860 	github.com/mattn/go-localereader v0.0.1 // indirect
 861@@ -67,18 +56,13 @@ require (
 862 	github.com/muesli/mango v0.1.0 // indirect
 863 	github.com/muesli/mango-pflag v0.1.0 // indirect
 864 	github.com/olekukonko/tablewriter v0.0.5 // indirect
 865-	github.com/pjbgf/sha1cd v0.3.0 // indirect
 866 	github.com/rivo/uniseg v0.2.0 // indirect
 867 	github.com/sahilm/fuzzy v0.1.0 // indirect
 868-	github.com/skeema/knownhosts v1.1.0 // indirect
 869 	github.com/spf13/pflag v1.0.5 // indirect
 870-	github.com/xanzy/ssh-agent v0.3.3 // indirect
 871 	github.com/yuin/goldmark v1.5.2 // indirect
 872 	github.com/yuin/goldmark-emoji v1.0.1 // indirect
 873 	golang.org/x/net v0.8.0 // indirect
 874-	golang.org/x/sync v0.1.0 // indirect
 875 	golang.org/x/sys v0.6.0 // indirect
 876 	golang.org/x/term v0.6.0 // indirect
 877 	golang.org/x/text v0.8.0 // indirect
 878-	gopkg.in/warnings.v0 v0.1.2 // indirect
 879 )
 880diff --git a/go.sum b/go.sum
 881index 9ecb2eaee4aba44c8f4e84adc250c0d8d4a170d4..626d920b1c3db6dedfe4381536bd047e47879539 100644
 882--- a/go.sum
 883+++ b/go.sum
 884@@ -1,19 +1,10 @@
 885-github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
 886-github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0=
 887-github.com/Microsoft/go-winio v0.5.2 h1:a9IhgEQBCUEk6QCdml9CiJGhAws+YwffDHEMp1VMrpA=
 888 github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
 889-github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo=
 890-github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA=
 891 github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g=
 892-github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
 893-github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ=
 894 github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
 895 github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
 896 github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
 897-github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
 898 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
 899 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
 900-github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
 901 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
 902 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
 903 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
 904@@ -46,11 +37,13 @@ github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0
 905 github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk=
 906 github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E=
 907 github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c=
 908+github.com/charmbracelet/log v0.1.2/go.mod h1:86XdIdmrubqtL/6u0z+jGFol1bQejBGG/qPSTwGZuQQ=
 909 github.com/charmbracelet/log v0.2.1 h1:1z7jpkk4yKyjwlmKmKMM5qnEDSpV32E7XtWhuv0mTZE=
 910 github.com/charmbracelet/log v0.2.1/go.mod h1:GwFfjewhcVDWLrpAbY5A0Hin9YOlEn40eWT4PNaxFT4=
 911-github.com/charmbracelet/wish v0.7.0 h1:rdfacCWaKCQpCMPbOKfi68GYqsb+9CnUzN1Ov/INZJ0=
 912-github.com/charmbracelet/wish v0.7.0/go.mod h1:16EQz7k3hEgPkPENghcpEddvlrmucIudE0jnczKr+k4=
 913-github.com/cloudflare/circl v1.1.0 h1:bZgT/A+cikZnKIwn7xL2OBj012Bmvho/o6RpRvv3GKY=
 914+github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103 h1:wpHMERIN0pQZE635jWwT1dISgfjbpUcEma+fbPKSMCU=
 915+github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103/go.mod h1:0Vm2/8yBljiLDnGJHU8ehswfawrEybGk33j5ssqKQVM=
 916+github.com/charmbracelet/wish v1.1.0 h1:0ArX9SOG70saqd23NYjoS56oLPVNgqcQegkz1Lw+4zY=
 917+github.com/charmbracelet/wish v1.1.0/go.mod h1:yHbm0hs/qX4lFE7nrhAcXjFYc8bxMIfSqJOfOYfwyYo=
 918 github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I=
 919 github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
 920 github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
 921@@ -63,23 +56,12 @@ github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E
 922 github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
 923 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
 924 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
 925-github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
 926-github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
 927 github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
 928-github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
 929-github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
 930-github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY=
 931 github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4=
 932-github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4=
 933 github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E=
 934-github.com/go-git/go-billy/v5 v5.2.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
 935 github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
 936-github.com/go-git/go-billy/v5 v5.4.1 h1:Uwp5tDRkPr+l/TnbHOQzp+tmJfLceOlbVucgpTz8ix4=
 937 github.com/go-git/go-billy/v5 v5.4.1/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg=
 938-github.com/go-git/go-git-fixtures/v4 v4.2.1/go.mod h1:K8zd3kDUAykwTdDCr+I0per6Y6vMiRR/nnVTBtavnB0=
 939-github.com/go-git/go-git-fixtures/v4 v4.3.1 h1:y5z6dd3qi8Hl+stezc8p3JxDkoTRqMAlKnXHuzrfjTQ=
 940 github.com/go-git/go-git-fixtures/v4 v4.3.1/go.mod h1:8LHG1a3SRW71ettAD/jW13h8c6AqjVSeL11RAdgaqpo=
 941-github.com/go-git/go-git/v5 v5.4.2/go.mod h1:gQ1kArt6d+n+BGd+/B/I74HwRTLhth2+zti4ihgckDc=
 942 github.com/go-git/go-git/v5 v5.6.1 h1:q4ZRqQl4pR/ZJHc1L5CFjGA1a10u76aV1iC+nh+bHsk=
 943 github.com/go-git/go-git/v5 v5.6.1/go.mod h1:mvyoL6Unz0PiTQrGQfSfiLFhBH1c1e84ylC2MDs4ee8=
 944 github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
 945@@ -88,28 +70,17 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
 946 github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
 947 github.com/gogs/git-module v1.8.1 h1:yC5BZ3unJOXC8N6/FgGQ8EtJXpOd217lgDcd2aPOxkc=
 948 github.com/gogs/git-module v1.8.1/go.mod h1:Y3rsSqtFZEbn7lp+3gWf42GKIY1eNTtLt7JrmOy0yAQ=
 949-github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
 950-github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 951-github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
 952-github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 953-github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
 954 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 955 github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
 956 github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
 957-github.com/hashicorp/golang-lru/v2 v2.0.1/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
 958-github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
 959-github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk=
 960+github.com/hashicorp/golang-lru/v2 v2.0.2/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
 961 github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg=
 962 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
 963 github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
 964 github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
 965-github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
 966 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
 967 github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
 968-github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
 969-github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
 970 github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
 971-github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 972 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 973 github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
 974 github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
 975@@ -124,7 +95,6 @@ github.com/lrstanley/bubblezone v0.0.0-20220716194435-3cb8c52f6a8f/go.mod h1:Cxa
 976 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
 977 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
 978 github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA=
 979-github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
 980 github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
 981 github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
 982 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
 983@@ -173,10 +143,7 @@ github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4AN
 984diff --git a/server/backend/file/file.go b/server/backend/file/file.go
 985index 9cdcd3e183e3214bb44ed90fc344c01f495f39ef..c29bc325833599241debe04b040bcdd383570c9c 100644
 986--- a/server/backend/file/file.go
 987+++ b/server/backend/file/file.go
 988@@ -32,7 +32,7 @@ import (
 989 	"github.com/charmbracelet/log"
 990 	"github.com/charmbracelet/soft-serve/git"
 991 	"github.com/charmbracelet/soft-serve/server/backend"
 992-	"github.com/gliderlabs/ssh"
 993+	"github.com/charmbracelet/ssh"
 994 	gitm "github.com/gogs/git-module"
 995 	gossh "golang.org/x/crypto/ssh"
 996 )
 997@@ -53,6 +53,14 @@ const (
 998 
 999 var (
1000 	logger = log.WithPrefix("backend.file")
1001+
1002+	defaults = map[string]string{
1003+		serverName:   "Soft Serve",
1004+		serverHost:   "localhost",
1005+		serverPort:   "23231",
1006+		anonAccess:   backend.ReadOnlyAccess.String(),
1007+		allowKeyless: "true",
1008+	}
1009 )
1010 
1011 var _ backend.Backend = &FileBackend{}
1012@@ -114,8 +122,17 @@ func NewFileBackend(path string) (*FileBackend, error) {
1013 		}
1014 	}
1015 	for _, file := range []string{admins, anonAccess, allowKeyless, serverHost, serverName, serverPort} {
1016-		if _, err := os.OpenFile(filepath.Join(path, file), os.O_RDONLY|os.O_CREATE, 0644); err != nil {
1017-			return nil, err
1018+		fp := filepath.Join(path, file)
1019+		_, err := os.Stat(fp)
1020+		if errors.Is(err, fs.ErrNotExist) {
1021+			f, err := os.Create(fp)
1022+			if err != nil {
1023+				return nil, err
1024+			}
1025+			if c, ok := defaults[file]; ok {
1026+				io.WriteString(f, c) // nolint:errcheck
1027+			}
1028+			_ = f.Close()
1029 		}
1030 	}
1031 	return fb, nil
1032@@ -586,10 +603,7 @@ func (fb *FileBackend) SetDefaultBranch(repo string, branch string) error {
1033 		return err
1034 	}
1035 
1036-	if _, err := r.SymbolicRef(gitm.SymbolicRefOptions{
1037-		Name: "HEAD",
1038-		Ref:  gitm.RefsHeads + branch,
1039-	}); err != nil {
1040+	if _, err := r.SymbolicRef("HEAD", gitm.RefsHeads+branch); err != nil {
1041 		logger.Debug("failed to set default branch", "err", err)
1042 		return err
1043 	}
1044diff --git a/server/backend/noop/noop.go b/server/backend/noop/noop.go
1045new file mode 100644
1046index 0000000000000000000000000000000000000000..c85e2b54bc07a3f1885b3fe9c7ddfdaae625c44f
1047--- /dev/null
1048+++ b/server/backend/noop/noop.go
1049@@ -0,0 +1,163 @@
1050+package noop
1051+
1052+import (
1053+	"fmt"
1054+	"os"
1055+	"path/filepath"
1056+
1057+	"github.com/charmbracelet/soft-serve/git"
1058+	"github.com/charmbracelet/soft-serve/server/backend"
1059+	"golang.org/x/crypto/ssh"
1060+)
1061+
1062+var ErrNotImpl = fmt.Errorf("not implemented")
1063+
1064+var _ backend.Backend = (*Noop)(nil)
1065+
1066+var _ backend.AccessMethod = (*Noop)(nil)
1067+
1068+// Noop is a backend that does nothing. It's used for testing.
1069+type Noop struct {
1070+	Port string
1071+}
1072+
1073+// AccessLevel implements backend.AccessMethod
1074+func (*Noop) AccessLevel(repo string, pk ssh.PublicKey) backend.AccessLevel {
1075+	return backend.AdminAccess
1076+}
1077+
1078+// AddAdmin implements backend.Backend
1079+func (*Noop) AddAdmin(pk ssh.PublicKey) error {
1080+	return ErrNotImpl
1081+}
1082+
1083+// AddCollaborator implements backend.Backend
1084+func (*Noop) AddCollaborator(pk ssh.PublicKey, repo string) error {
1085+	return ErrNotImpl
1086+}
1087+
1088+// AllowKeyless implements backend.Backend
1089+func (*Noop) AllowKeyless() bool {
1090+	return true
1091+}
1092+
1093+// AnonAccess implements backend.Backend
1094+func (*Noop) AnonAccess() backend.AccessLevel {
1095+	return backend.AdminAccess
1096+}
1097+
1098+// CreateRepository implements backend.Backend
1099+func (*Noop) CreateRepository(name string, private bool) (backend.Repository, error) {
1100+	temp, err := os.MkdirTemp("", "soft-serve")
1101+	if err != nil {
1102+		return nil, err
1103+	}
1104+
1105+	rp := filepath.Join(temp, name)
1106+	_, err = git.Init(rp, private)
1107+	if err != nil {
1108+		return nil, err
1109+	}
1110+
1111+	return &repo{path: rp}, nil
1112+}
1113+
1114+// DefaultBranch implements backend.Backend
1115+func (*Noop) DefaultBranch(repo string) (string, error) {
1116+	return "", ErrNotImpl
1117+}
1118+
1119+// DeleteRepository implements backend.Backend
1120+func (*Noop) DeleteRepository(name string) error {
1121+	return ErrNotImpl
1122+}
1123+
1124+// Description implements backend.Backend
1125+func (*Noop) Description(repo string) string {
1126+	return ""
1127+}
1128+
1129+// IsAdmin implements backend.Backend
1130+func (*Noop) IsAdmin(pk ssh.PublicKey) bool {
1131+	return true
1132+}
1133+
1134+// IsCollaborator implements backend.Backend
1135+func (*Noop) IsCollaborator(pk ssh.PublicKey, repo string) bool {
1136+	return true
1137+}
1138+
1139+// IsPrivate implements backend.Backend
1140+func (*Noop) IsPrivate(repo string) bool {
1141+	return false
1142+}
1143+
1144+// RenameRepository implements backend.Backend
1145+func (*Noop) RenameRepository(oldName string, newName string) error {
1146+	return ErrNotImpl
1147+}
1148+
1149diff --git a/server/backend/noop/repo.go b/server/backend/noop/repo.go
1150new file mode 100644
1151index 0000000000000000000000000000000000000000..80cbbcf2ba563b19833c793df96f42cbb36b58bf
1152--- /dev/null
1153+++ b/server/backend/noop/repo.go
1154@@ -0,0 +1,32 @@
1155+package noop
1156+
1157+import (
1158+	"github.com/charmbracelet/soft-serve/git"
1159+	"github.com/charmbracelet/soft-serve/server/backend"
1160+)
1161+
1162+var _ backend.Repository = (*repo)(nil)
1163+
1164+type repo struct {
1165+	path string
1166+}
1167+
1168+// Description implements backend.Repository
1169+func (*repo) Description() string {
1170+	return ""
1171+}
1172+
1173+// IsPrivate implements backend.Repository
1174+func (*repo) IsPrivate() bool {
1175+	return false
1176+}
1177+
1178+// Name implements backend.Repository
1179+func (*repo) Name() string {
1180+	return ""
1181+}
1182+
1183+// Repository implements backend.Repository
1184+func (r *repo) Repository() (*git.Repository, error) {
1185+	return git.Open(r.path)
1186+}
1187diff --git a/server/cmd/cat.go b/server/cmd/cat.go
1188deleted file mode 100644
1189index 75008918675b09849c19b662e83b0ccf6e205f34..0000000000000000000000000000000000000000
1190--- a/server/cmd/cat.go
1191+++ /dev/null
1192@@ -1,123 +0,0 @@
1193-package cmd
1194-
1195-import (
1196-	"fmt"
1197-	"strings"
1198-
1199-	"github.com/alecthomas/chroma/lexers"
1200-	gansi "github.com/charmbracelet/glamour/ansi"
1201-	"github.com/charmbracelet/lipgloss"
1202-	"github.com/charmbracelet/soft-serve/config"
1203-	"github.com/charmbracelet/soft-serve/ui/common"
1204-	gitwish "github.com/charmbracelet/wish/git"
1205-	"github.com/muesli/termenv"
1206-	"github.com/spf13/cobra"
1207-)
1208-
1209-var (
1210-	lineDigitStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("239"))
1211-	lineBarStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("236"))
1212-	dirnameStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("#00AAFF"))
1213-	filenameStyle  = lipgloss.NewStyle()
1214-	filemodeStyle  = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777"))
1215-)
1216-
1217-// CatCommand returns a command that prints the contents of a file.
1218-func CatCommand() *cobra.Command {
1219-	var linenumber bool
1220-	var color bool
1221-
1222-	catCmd := &cobra.Command{
1223-		Use:   "cat PATH",
1224-		Short: "Outputs the contents of the file at path.",
1225-		Args:  cobra.ExactArgs(1),
1226-		RunE: func(cmd *cobra.Command, args []string) error {
1227-			ac, s := fromContext(cmd)
1228-			ps := strings.Split(args[0], "/")
1229-			rn := ps[0]
1230-			fp := strings.Join(ps[1:], "/")
1231-			auth := ac.AuthRepo(rn, s.PublicKey())
1232-			if auth < gitwish.ReadOnlyAccess {
1233-				return ErrUnauthorized
1234-			}
1235-			var repo *config.Repo
1236-			repoExists := false
1237-			for _, rp := range ac.Source.AllRepos() {
1238-				if rp.Repo() == rn {
1239-					repoExists = true
1240-					repo = rp
1241-					break
1242-				}
1243-			}
1244-			if !repoExists {
1245-				return ErrRepoNotFound
1246-			}
1247-			c, _, err := repo.LatestFile(fp)
1248-			if err != nil {
1249-				return err
1250-			}
1251-			if color {
1252-				c, err = withFormatting(fp, c)
1253-				if err != nil {
1254-					return err
1255-				}
1256-			}
1257-			if linenumber {
1258-				c = withLineNumber(c, color)
1259-			}
1260-			fmt.Fprint(s, c)
1261-			return nil
1262-		},
1263-	}
1264-	catCmd.Flags().BoolVarP(&linenumber, "linenumber", "l", false, "Print line numbers")
1265-	catCmd.Flags().BoolVarP(&color, "color", "c", false, "Colorize output")
1266-
1267-	return catCmd
1268-}
1269-
1270-func withLineNumber(s string, color bool) string {
1271-	lines := strings.Split(s, "\n")
1272-	// NB: len() is not a particularly safe way to count string width (because
1273-	// it's counting bytes instead of runes) but in this case it's okay
1274-	// because we're only dealing with digits, which are one byte each.
1275-	mll := len(fmt.Sprintf("%d", len(lines)))
1276-	for i, l := range lines {
1277-		digit := fmt.Sprintf("%*d", mll, i+1)
1278-		bar := "│"
1279-		if color {
1280-			digit = lineDigitStyle.Render(digit)
1281-			bar = lineBarStyle.Render(bar)
1282-		}
1283-		if i < len(lines)-1 || len(l) != 0 {
1284-			// If the final line was a newline we'll get an empty string for
1285-			// the final line, so drop the newline altogether.
1286-			lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l)
1287-		}
1288-	}
1289-	return strings.Join(lines, "\n")
1290-}
1291-
1292diff --git a/server/cmd/cmd.go b/server/cmd/cmd.go
1293index 684fe7ec65fb17559bceb05151208158b02bb134..00cfc3f26a8515c01344517fb607cd40dfa32b83 100644
1294--- a/server/cmd/cmd.go
1295+++ b/server/cmd/cmd.go
1296@@ -3,8 +3,8 @@ package cmd
1297 import (
1298 	"fmt"
1299 
1300-	appCfg "github.com/charmbracelet/soft-serve/config"
1301-	"github.com/gliderlabs/ssh"
1302+	"github.com/charmbracelet/soft-serve/server/config"
1303+	"github.com/charmbracelet/ssh"
1304 	"github.com/spf13/cobra"
1305 )
1306 
1307@@ -68,18 +68,15 @@ func RootCommand() *cobra.Command {
1308 	rootCmd.SetUsageTemplate(usageTemplate)
1309 	rootCmd.CompletionOptions.DisableDefaultCmd = true
1310 	rootCmd.AddCommand(
1311-		ReloadCommand(),
1312-		CatCommand(),
1313-		ListCommand(),
1314-		GitCommand(),
1315+		RepoCommand(),
1316 	)
1317 
1318 	return rootCmd
1319 }
1320 
1321-func fromContext(cmd *cobra.Command) (*appCfg.Config, ssh.Session) {
1322+func fromContext(cmd *cobra.Command) (*config.Config, ssh.Session) {
1323 	ctx := cmd.Context()
1324-	ac := ctx.Value(ConfigCtxKey).(*appCfg.Config)
1325+	cfg := ctx.Value(ConfigCtxKey).(*config.Config)
1326 	s := ctx.Value(SessionCtxKey).(ssh.Session)
1327-	return ac, s
1328+	return cfg, s
1329 }
1330diff --git a/server/cmd/git.go b/server/cmd/git.go
1331deleted file mode 100644
1332index d9a60070e6bdbf3ab3d1419d758bf93be75fc2c8..0000000000000000000000000000000000000000
1333--- a/server/cmd/git.go
1334+++ /dev/null
1335@@ -1,54 +0,0 @@
1336-package cmd
1337-
1338-import (
1339-	"io"
1340-	"os/exec"
1341-
1342-	"github.com/charmbracelet/soft-serve/config"
1343-	gitwish "github.com/charmbracelet/wish/git"
1344-	"github.com/spf13/cobra"
1345-)
1346-
1347-// GitCommand returns a command that handles Git operations.
1348-func GitCommand() *cobra.Command {
1349-	gitCmd := &cobra.Command{
1350-		Use:   "git REPO COMMAND",
1351-		Short: "Perform Git operations on a repository.",
1352-		RunE: func(cmd *cobra.Command, args []string) error {
1353-			ac, s := fromContext(cmd)
1354-			auth := ac.AuthRepo("config", s.PublicKey())
1355-			if auth < gitwish.AdminAccess {
1356-				return ErrUnauthorized
1357-			}
1358-			if len(args) < 1 {
1359-				return runGit(nil, s, s, "")
1360-			}
1361-			var repo *config.Repo
1362-			rn := args[0]
1363-			repoExists := false
1364-			for _, rp := range ac.Source.AllRepos() {
1365-				if rp.Repo() == rn {
1366-					repoExists = true
1367-					repo = rp
1368-					break
1369-				}
1370-			}
1371-			if !repoExists {
1372-				return ErrRepoNotFound
1373-			}
1374-			return runGit(nil, s, s, repo.Path(), args[1:]...)
1375-		},
1376-	}
1377-	gitCmd.Flags().SetInterspersed(false)
1378-
1379-	return gitCmd
1380-}
1381-
1382-func runGit(in io.Reader, out, err io.Writer, dir string, args ...string) error {
1383-	cmd := exec.Command("git", args...)
1384-	cmd.Stdin = in
1385-	cmd.Stdout = out
1386-	cmd.Stderr = err
1387-	cmd.Dir = dir
1388-	return cmd.Run()
1389-}
1390diff --git a/server/cmd/list.go b/server/cmd/list.go
1391deleted file mode 100644
1392index ff4045c5b1f8a3fbbe6c2f6ca8eea42e928c7f34..0000000000000000000000000000000000000000
1393--- a/server/cmd/list.go
1394+++ /dev/null
1395@@ -1,83 +0,0 @@
1396-package cmd
1397-
1398-import (
1399-	"fmt"
1400-	"path/filepath"
1401-	"strings"
1402-
1403-	"github.com/charmbracelet/soft-serve/git"
1404-	gitwish "github.com/charmbracelet/wish/git"
1405-	"github.com/spf13/cobra"
1406-)
1407-
1408-// ListCommand returns a command that list file or directory at path.
1409-func ListCommand() *cobra.Command {
1410-	lsCmd := &cobra.Command{
1411-		Use:     "ls PATH",
1412-		Aliases: []string{"list"},
1413-		Short:   "List file or directory at path.",
1414-		Args:    cobra.RangeArgs(0, 1),
1415-		RunE: func(cmd *cobra.Command, args []string) error {
1416-			ac, s := fromContext(cmd)
1417-			rn := ""
1418-			path := ""
1419-			ps := []string{}
1420-			if len(args) > 0 {
1421-				path = filepath.Clean(args[0])
1422-				ps = strings.Split(path, "/")
1423-				rn = ps[0]
1424-				auth := ac.AuthRepo(rn, s.PublicKey())
1425-				if auth < gitwish.ReadOnlyAccess {
1426-					return ErrUnauthorized
1427-				}
1428-			}
1429-			if path == "" || path == "." || path == "/" {
1430-				for _, r := range ac.Source.AllRepos() {
1431-					if ac.AuthRepo(r.Repo(), s.PublicKey()) >= gitwish.ReadOnlyAccess {
1432-						fmt.Fprintln(s, r.Repo())
1433-					}
1434-				}
1435-				return nil
1436-			}
1437-			r, err := ac.Source.GetRepo(rn)
1438-			if err != nil {
1439-				return err
1440-			}
1441-			head, err := r.HEAD()
1442-			if err != nil {
1443-				return err
1444-			}
1445-			tree, err := r.Tree(head, "")
1446-			if err != nil {
1447-				return err
1448-			}
1449-			subpath := strings.Join(ps[1:], "/")
1450-			ents := git.Entries{}
1451-			te, err := tree.TreeEntry(subpath)
1452-			if err == git.ErrRevisionNotExist {
1453-				return ErrFileNotFound
1454-			}
1455-			if err != nil {
1456-				return err
1457-			}
1458-			if te.Type() == "tree" {
1459-				tree, err = tree.SubTree(subpath)
1460-				if err != nil {
1461-					return err
1462-				}
1463-				ents, err = tree.Entries()
1464-				if err != nil {
1465-					return err
1466-				}
1467-			} else {
1468-				ents = append(ents, te)
1469-			}
1470-			ents.Sort()
1471-			for _, ent := range ents {
1472-				fmt.Fprintf(s, "%s\t%d\t %s\n", ent.Mode(), ent.Size(), ent.Name())
1473-			}
1474-			return nil
1475-		},
1476-	}
1477-	return lsCmd
1478-}
1479diff --git a/server/middleware.go b/server/cmd/middleware.go
1480rename from server/middleware.go
1481rename to server/cmd/middleware.go
1482index a4969f5fe5582929bc7ed6ddca571d225c4514d0..8635a66507a980fe3cb5b292fc682d082f5767dd 100644
1483--- a/server/middleware.go
1484+++ b/server/cmd/middleware.go
1485@@ -1,17 +1,16 @@
1486-package server
1487+package cmd
1488 
1489 import (
1490 	"context"
1491 	"fmt"
1492 
1493-	appCfg "github.com/charmbracelet/soft-serve/config"
1494-	"github.com/charmbracelet/soft-serve/server/cmd"
1495+	"github.com/charmbracelet/soft-serve/server/config"
1496+	"github.com/charmbracelet/ssh"
1497 	"github.com/charmbracelet/wish"
1498-	"github.com/gliderlabs/ssh"
1499 )
1500 
1501-// softMiddleware is the Soft Serve middleware that handles SSH commands.
1502-func softMiddleware(ac *appCfg.Config) wish.Middleware {
1503+// Middleware is the Soft Serve middleware that handles SSH commands.
1504+func Middleware(cfg *config.Config) wish.Middleware {
1505 	return func(sh ssh.Handler) ssh.Handler {
1506 		return func(s ssh.Session) {
1507 			func() {
1508@@ -19,16 +18,16 @@ func softMiddleware(ac *appCfg.Config) wish.Middleware {
1509 				if active {
1510 					return
1511 				}
1512-				ctx := context.WithValue(s.Context(), cmd.ConfigCtxKey, ac)
1513-				ctx = context.WithValue(ctx, cmd.SessionCtxKey, s)
1514+				ctx := context.WithValue(s.Context(), ConfigCtxKey, cfg)
1515+				ctx = context.WithValue(ctx, SessionCtxKey, s)
1516 
1517 				use := "ssh"
1518-				port := ac.Port
1519-				if port != 22 {
1520-					use += fmt.Sprintf(" -p%d", port)
1521+				port := cfg.Backend.ServerPort()
1522+				if port != "22" {
1523+					use += fmt.Sprintf(" -p%s", port)
1524 				}
1525-				use += fmt.Sprintf(" %s", ac.Host)
1526-				cmd := cmd.RootCommand()
1527+				use += fmt.Sprintf(" %s", cfg.Backend.ServerHost())
1528+				cmd := RootCommand()
1529 				cmd.Use = use
1530 				cmd.CompletionOptions.DisableDefaultCmd = true
1531 				cmd.SetIn(s)
1532diff --git a/server/cmd/reload.go b/server/cmd/reload.go
1533deleted file mode 100644
1534index 7f2312ab6e550109e9c52d0ee9c0ef80f3fa1253..0000000000000000000000000000000000000000
1535--- a/server/cmd/reload.go
1536+++ /dev/null
1537@@ -1,23 +0,0 @@
1538-package cmd
1539-
1540-import (
1541-	gitwish "github.com/charmbracelet/wish/git"
1542-	"github.com/spf13/cobra"
1543-)
1544-
1545-// ReloadCommand returns a command that reloads the server configuration.
1546-func ReloadCommand() *cobra.Command {
1547-	reloadCmd := &cobra.Command{
1548-		Use:   "reload",
1549-		Short: "Reloads the configuration",
1550-		RunE: func(cmd *cobra.Command, args []string) error {
1551-			ac, s := fromContext(cmd)
1552-			auth := ac.AuthRepo("config", s.PublicKey())
1553-			if auth < gitwish.AdminAccess {
1554-				return ErrUnauthorized
1555-			}
1556-			return ac.Reload()
1557-		},
1558-	}
1559-	return reloadCmd
1560-}
1561diff --git a/server/cmd/repo.go b/server/cmd/repo.go
1562new file mode 100644
1563index 0000000000000000000000000000000000000000..c2324bee76cc0f0b919d19c499d377df90be3f43
1564--- /dev/null
1565+++ b/server/cmd/repo.go
1566@@ -0,0 +1,408 @@
1567+package cmd
1568+
1569+import (
1570+	"fmt"
1571+	"path/filepath"
1572+	"strconv"
1573+	"strings"
1574+
1575+	"github.com/alecthomas/chroma/lexers"
1576+	gansi "github.com/charmbracelet/glamour/ansi"
1577+	"github.com/charmbracelet/lipgloss"
1578+	"github.com/charmbracelet/soft-serve/git"
1579+	"github.com/charmbracelet/soft-serve/server/backend"
1580+	"github.com/charmbracelet/soft-serve/ui/common"
1581+	"github.com/muesli/termenv"
1582+	"github.com/spf13/cobra"
1583+)
1584+
1585+// RepoCommand is the command for managing repositories.
1586+func RepoCommand() *cobra.Command {
1587+	cmd := &cobra.Command{
1588+		Use:     "repo COMMAND",
1589+		Aliases: []string{"repository", "repositories"},
1590+		Short:   "Manage repositories.",
1591+	}
1592+	cmd.AddCommand(
1593+		setCommand(),
1594+		createCommand(),
1595+		deleteCommand(),
1596+		listCommand(),
1597+		showCommand(),
1598+	)
1599+	return cmd
1600+}
1601+
1602+func setCommand() *cobra.Command {
1603+	cmd := &cobra.Command{
1604+		Use:   "set",
1605+		Short: "Set repository properties.",
1606+	}
1607+	cmd.AddCommand(
1608+		setName(),
1609+		setDescription(),
1610+		setPrivate(),
1611+		setDefaultBranch(),
1612+	)
1613+	return cmd
1614+}
1615+
1616+// createCommand is the command for creating a new repository.
1617+func createCommand() *cobra.Command {
1618+	var private bool
1619+	var description string
1620+	var projectName string
1621+	cmd := &cobra.Command{
1622+		Use:   "create REPOSITORY",
1623+		Short: "Create a new repository.",
1624+		Args:  cobra.ExactArgs(1),
1625+		PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
1626+			cfg, s := fromContext(cmd)
1627+			if !cfg.Backend.IsAdmin(s.PublicKey()) {
1628+				return ErrUnauthorized
1629+			}
1630+			return nil
1631+		},
1632+		RunE: func(cmd *cobra.Command, args []string) error {
1633+			cfg, _ := fromContext(cmd)
1634+			name := args[0]
1635+			if _, err := cfg.Backend.CreateRepository(name, private); err != nil {
1636+				return err
1637+			}
1638+			return nil
1639+		},
1640+	}
1641+	cmd.Flags().BoolVarP(&private, "private", "p", false, "make the repository private")
1642+	cmd.Flags().StringVarP(&description, "description", "d", "", "set the repository description")
1643+	cmd.Flags().StringVarP(&projectName, "project-name", "n", "", "set the project name")
1644+	return cmd
1645+}
1646+
1647+func deleteCommand() *cobra.Command {
1648+	cmd := &cobra.Command{
1649+		Use:               "delete REPOSITORY",
1650+		Short:             "Delete a repository.",
1651+		Args:              cobra.ExactArgs(1),
1652+		PersistentPreRunE: checkIfAdmin,
1653+		RunE: func(cmd *cobra.Command, args []string) error {
1654+			cfg, _ := fromContext(cmd)
1655+			name := args[0]
1656+			if err := cfg.Backend.DeleteRepository(name); err != nil {
1657+				return err
1658+			}
1659+			return nil
1660+		},
1661+	}
1662+	return cmd
1663+}
1664+
1665+func checkIfReadable(cmd *cobra.Command, args []string) error {
1666diff --git a/server/config/config.go b/server/config/config.go
1667index 69b3a7098670d2655ce915b86606f3dee7510e54..14dcff657128d87749f1ea4d3df56049eeb6525b 100644
1668--- a/server/config/config.go
1669+++ b/server/config/config.go
1670@@ -1,58 +1,93 @@
1671 package config
1672 
1673 import (
1674-	glog "log"
1675-	"path/filepath"
1676-
1677 	"github.com/caarlos0/env/v6"
1678 	"github.com/charmbracelet/log"
1679+	"github.com/charmbracelet/soft-serve/server/backend"
1680+	"github.com/charmbracelet/soft-serve/server/backend/file"
1681 )
1682 
1683-// Callbacks provides an interface that can be used to run callbacks on different events.
1684-type Callbacks interface {
1685-	Tui(action string)
1686-	Push(repo string)
1687-	Fetch(repo string)
1688+// SSHConfig is the configuration for the SSH server.
1689+type SSHConfig struct {
1690+	// ListenAddr is the address on which the SSH server will listen.
1691+	ListenAddr string `env:"LISTEN_ADDR" envDefault:":23231"`
1692+
1693+	// KeyPath is the path to the SSH server's private key.
1694+	KeyPath string `env:"KEY_PATH" envDefault:"soft_serve"`
1695+
1696+	// MaxTimeout is the maximum number of seconds a connection can take.
1697+	MaxTimeout int `env:"MAX_TIMEOUT" envDefault:"0"`
1698+
1699+	// IdleTimeout is the number of seconds a connection can be idle before it is closed.
1700+	IdleTimeout int `env:"IDLE_TIMEOUT" envDefault:"120"`
1701+}
1702+
1703+// GitConfig is the Git daemon configuration for the server.
1704+type GitConfig struct {
1705+	// ListenAddr is the address on which the Git daemon will listen.
1706+	ListenAddr string `env:"LISTEN_ADDR" envDefault:":9418"`
1707+
1708+	// MaxTimeout is the maximum number of seconds a connection can take.
1709+	MaxTimeout int `env:"MAX_TIMEOUT" envDefault:"0"`
1710+
1711+	// IdleTimeout is the number of seconds a connection can be idle before it is closed.
1712+	IdleTimeout int `env:"IDLE_TIMEOUT" envDefault:"3"`
1713+
1714+	// MaxConnections is the maximum number of concurrent connections.
1715+	MaxConnections int `env:"MAX_CONNECTIONS" envDefault:"32"`
1716 }
1717 
1718 // Config is the configuration for Soft Serve.
1719 type Config struct {
1720-	BindAddr         string   `env:"SOFT_SERVE_BIND_ADDRESS" envDefault:""`
1721-	Host             string   `env:"SOFT_SERVE_HOST" envDefault:"localhost"`
1722-	Port             int      `env:"SOFT_SERVE_PORT" envDefault:"23231"`
1723-	KeyPath          string   `env:"SOFT_SERVE_KEY_PATH"`
1724-	RepoPath         string   `env:"SOFT_SERVE_REPO_PATH" envDefault:".repos"`
1725-	Debug            bool     `env:"SOFT_SERVE_DEBUG" envDefault:"false"`
1726-	InitialAdminKeys []string `env:"SOFT_SERVE_INITIAL_ADMIN_KEY" envSeparator:"\n"`
1727-	Callbacks        Callbacks
1728-	ErrorLog         *glog.Logger
1729+	// SSH is the configuration for the SSH server.
1730+	SSH SSHConfig `envPrefix:"SSH_"`
1731+
1732+	// Git is the configuration for the Git daemon.
1733+	Git GitConfig `envPrefix:"GIT_"`
1734+
1735+	// InitialAdminKeys is a list of public keys that will be added to the list of admins.
1736+	InitialAdminKeys []string `env:"INITIAL_ADMIN_KEY" envSeparator:"\n"`
1737+
1738+	// DataPath is the path to the directory where Soft Serve will store its data.
1739+	DataPath string `env:"DATA_PATH" envDefault:"data"`
1740+
1741+	// Debug enables debug logging.
1742+	Debug bool `env:"DEBUG" envDefault:"false"`
1743+
1744+	// Backend is the Git backend to use.
1745+	Backend backend.Backend
1746+
1747+	// Access is the access control backend to use.
1748+	Access backend.AccessMethod
1749 }
1750 
1751 // DefaultConfig returns a Config with the values populated with the defaults
1752 // or specified environment variables.
1753 func DefaultConfig() *Config {
1754-	cfg := &Config{ErrorLog: log.StandardLog(log.StandardLogOptions{ForceLevel: log.ErrorLevel})}
1755-	if err := env.Parse(cfg); err != nil {
1756+	cfg := &Config{}
1757+	if err := env.Parse(cfg, env.Options{
1758+		Prefix: "SOFT_SERVE_",
1759+	}); err != nil {
1760 		log.Fatal(err)
1761 	}
1762 	if cfg.Debug {
1763 		log.SetLevel(log.DebugLevel)
1764 	}
1765-	if cfg.KeyPath == "" {
1766-		// NB: cross-platform-compatible path
1767-		cfg.KeyPath = filepath.Join(".ssh", "soft_serve_server_ed25519")
1768+	fb, err := file.NewFileBackend(cfg.DataPath)
1769+	if err != nil {
1770diff --git a/server/daemon.go b/server/daemon.go
1771new file mode 100644
1772index 0000000000000000000000000000000000000000..0055ff9c6ce05726dbdb39d3e51eaeabc5935be0
1773--- /dev/null
1774+++ b/server/daemon.go
1775@@ -0,0 +1,299 @@
1776+package server
1777+
1778+import (
1779+	"bytes"
1780+	"context"
1781+	"errors"
1782+	"io"
1783+	"log"
1784+	"net"
1785+	"path/filepath"
1786+	"strings"
1787+	"sync"
1788+	"time"
1789+
1790+	"github.com/charmbracelet/soft-serve/server/backend"
1791+	"github.com/charmbracelet/soft-serve/server/config"
1792+	"github.com/go-git/go-git/v5/plumbing/format/pktline"
1793+)
1794+
1795+// ErrServerClosed indicates that the server has been closed.
1796+var ErrServerClosed = errors.New("git: Server closed")
1797+
1798+// connections synchronizes access to to a net.Conn pool.
1799+type connections struct {
1800+	m  map[net.Conn]struct{}
1801+	mu sync.Mutex
1802+}
1803+
1804+func (m *connections) Add(c net.Conn) {
1805+	m.mu.Lock()
1806+	defer m.mu.Unlock()
1807+	m.m[c] = struct{}{}
1808+}
1809+
1810+func (m *connections) Close(c net.Conn) {
1811+	m.mu.Lock()
1812+	defer m.mu.Unlock()
1813+	_ = c.Close()
1814+	delete(m.m, c)
1815+}
1816+
1817+func (m *connections) Size() int {
1818+	m.mu.Lock()
1819+	defer m.mu.Unlock()
1820+	return len(m.m)
1821+}
1822+
1823+func (m *connections) CloseAll() {
1824+	m.mu.Lock()
1825+	defer m.mu.Unlock()
1826+	for c := range m.m {
1827+		_ = c.Close()
1828+		delete(m.m, c)
1829+	}
1830+}
1831+
1832+// GitDaemon represents a Git daemon.
1833+type GitDaemon struct {
1834+	listener net.Listener
1835+	addr     string
1836+	finished chan struct{}
1837+	conns    connections
1838+	cfg      *config.Config
1839+	wg       sync.WaitGroup
1840+	once     sync.Once
1841+}
1842+
1843+// NewDaemon returns a new Git daemon.
1844+func NewGitDaemon(cfg *config.Config) (*GitDaemon, error) {
1845+	addr := cfg.Git.ListenAddr
1846+	d := &GitDaemon{
1847+		addr:     addr,
1848+		finished: make(chan struct{}, 1),
1849+		cfg:      cfg,
1850+		conns:    connections{m: make(map[net.Conn]struct{})},
1851+	}
1852+	listener, err := net.Listen("tcp", d.addr)
1853+	if err != nil {
1854+		return nil, err
1855+	}
1856+	d.listener = listener
1857+	return d, nil
1858+}
1859+
1860+// Start starts the Git TCP daemon.
1861+func (d *GitDaemon) Start() error {
1862+	defer d.listener.Close() // nolint: errcheck
1863+
1864+	d.wg.Add(1)
1865+	defer d.wg.Done()
1866+
1867+	var tempDelay time.Duration
1868+	for {
1869+		conn, err := d.listener.Accept()
1870+		if err != nil {
1871+			select {
1872+			case <-d.finished:
1873+				return ErrServerClosed
1874+			default:
1875diff --git a/server/daemon_test.go b/server/daemon_test.go
1876new file mode 100644
1877index 0000000000000000000000000000000000000000..e2e059e42cd6553cf5b707a386061204a1852b98
1878--- /dev/null
1879+++ b/server/daemon_test.go
1880@@ -0,0 +1,95 @@
1881+package server
1882+
1883+import (
1884+	"bytes"
1885+	"errors"
1886+	"fmt"
1887+	"io"
1888+	"log"
1889+	"net"
1890+	"os"
1891+	"strings"
1892+	"testing"
1893+	"time"
1894+
1895+	"github.com/charmbracelet/soft-serve/server/config"
1896+	"github.com/go-git/go-git/v5/plumbing/format/pktline"
1897+)
1898+
1899+var testDaemon *GitDaemon
1900+
1901+func TestMain(m *testing.M) {
1902+	tmp, err := os.MkdirTemp("", "soft-serve-test")
1903+	if err != nil {
1904+		log.Fatal(err)
1905+	}
1906+	defer os.RemoveAll(tmp)
1907+	os.Setenv("SOFT_SERVE_DATA_PATH", tmp)
1908+	os.Setenv("SOFT_SERVE_GIT_MAX_CONNECTIONS", "3")
1909+	os.Setenv("SOFT_SERVE_GIT_MAX_TIMEOUT", "100")
1910+	os.Setenv("SOFT_SERVE_GIT_IDLE_TIMEOUT", "1")
1911+	os.Setenv("SOFT_SERVE_GIT_LISTEN_ADDR", fmt.Sprintf(":%d", randomPort()))
1912+	cfg := config.DefaultConfig()
1913+	d, err := NewGitDaemon(cfg)
1914+	if err != nil {
1915+		log.Fatal(err)
1916+	}
1917+	testDaemon = d
1918+	go func() {
1919+		if err := d.Start(); err != ErrServerClosed {
1920+			log.Fatal(err)
1921+		}
1922+	}()
1923+	code := m.Run()
1924+	os.Unsetenv("SOFT_SERVE_DATA_PATH")
1925+	os.Unsetenv("SOFT_SERVE_GIT_MAX_CONNECTIONS")
1926+	os.Unsetenv("SOFT_SERVE_GIT_MAX_TIMEOUT")
1927+	os.Unsetenv("SOFT_SERVE_GIT_IDLE_TIMEOUT")
1928+	os.Unsetenv("SOFT_SERVE_GIT_LISTEN_ADDR")
1929+	_ = d.Close()
1930+	os.Exit(code)
1931+}
1932+
1933+func TestIdleTimeout(t *testing.T) {
1934+	c, err := net.Dial("tcp", testDaemon.addr)
1935+	if err != nil {
1936+		t.Fatal(err)
1937+	}
1938+	time.Sleep(2 * time.Second)
1939+	out, err := readPktline(c)
1940+	if err != nil && !errors.Is(err, io.EOF) {
1941+		t.Fatalf("expected nil, got error: %v", err)
1942+	}
1943+	if out != ErrTimeout.Error() {
1944+		t.Fatalf("expected %q error, got %q", ErrTimeout, out)
1945+	}
1946+}
1947+
1948+func TestInvalidRepo(t *testing.T) {
1949+	c, err := net.Dial("tcp", testDaemon.addr)
1950+	if err != nil {
1951+		t.Fatal(err)
1952+	}
1953+	if err := pktline.NewEncoder(c).EncodeString("git-upload-pack /test.git\x00"); err != nil {
1954+		t.Fatalf("expected nil, got error: %v", err)
1955+	}
1956+	out, err := readPktline(c)
1957+	if err != nil {
1958+		t.Fatalf("expected nil, got error: %v", err)
1959+	}
1960+	if out != ErrInvalidRepo.Error() {
1961+		t.Fatalf("expected %q error, got %q", ErrInvalidRepo, out)
1962+	}
1963+}
1964+
1965+func readPktline(c net.Conn) (string, error) {
1966+	buf, err := io.ReadAll(c)
1967+	if err != nil {
1968+		return "", err
1969+	}
1970+	pktout := pktline.NewScanner(bytes.NewReader(buf))
1971+	if !pktout.Scan() {
1972+		return "", pktout.Err()
1973+	}
1974+	return strings.TrimSpace(string(pktout.Bytes())), nil
1975+}
1976diff --git a/server/git.go b/server/git.go
1977new file mode 100644
1978index 0000000000000000000000000000000000000000..812dddfc46c37f6952bcd2bfebaf7286a16d129e
1979--- /dev/null
1980+++ b/server/git.go
1981@@ -0,0 +1,162 @@
1982+package server
1983+
1984+import (
1985+	"errors"
1986+	"fmt"
1987+	"io"
1988+	"log"
1989+	"os"
1990+	"path/filepath"
1991+
1992+	"github.com/charmbracelet/soft-serve/git"
1993+	"github.com/go-git/go-git/v5/plumbing/format/pktline"
1994+)
1995+
1996+var (
1997+
1998+	// ErrNotAuthed represents unauthorized access.
1999+	ErrNotAuthed = errors.New("you are not authorized to do this")
2000+
2001+	// ErrSystemMalfunction represents a general system error returned to clients.
2002+	ErrSystemMalfunction = errors.New("something went wrong")
2003+
2004+	// ErrInvalidRepo represents an attempt to access a non-existent repo.
2005+	ErrInvalidRepo = errors.New("invalid repo")
2006+
2007+	// ErrInvalidRequest represents an invalid request.
2008+	ErrInvalidRequest = errors.New("invalid request")
2009+
2010+	// ErrMaxConnections represents a maximum connection limit being reached.
2011+	ErrMaxConnections = errors.New("too many connections, try again later")
2012+
2013+	// ErrTimeout is returned when the maximum read timeout is exceeded.
2014+	ErrTimeout = errors.New("I/O timeout reached")
2015+)
2016+
2017+// Git protocol commands.
2018+const (
2019+	ReceivePackBin   = "git-receive-pack"
2020+	UploadPackBin    = "git-upload-pack"
2021+	UploadArchiveBin = "git-upload-archive"
2022+)
2023+
2024+// UploadPack runs the git upload-pack protocol against the provided repo.
2025+func UploadPack(in io.Reader, out io.Writer, er io.Writer, repoDir string) error {
2026+	exists, err := fileExists(repoDir)
2027+	if !exists {
2028+		return ErrInvalidRepo
2029+	}
2030+	if err != nil {
2031+		return err
2032+	}
2033+	return RunGit(in, out, er, "", UploadPackBin[4:], repoDir)
2034+}
2035+
2036+// UploadArchive runs the git upload-archive protocol against the provided repo.
2037+func UploadArchive(in io.Reader, out io.Writer, er io.Writer, repoDir string) error {
2038+	exists, err := fileExists(repoDir)
2039+	if !exists {
2040+		return ErrInvalidRepo
2041+	}
2042+	if err != nil {
2043+		return err
2044+	}
2045+	return RunGit(in, out, er, "", UploadArchiveBin[4:], repoDir)
2046+}
2047+
2048+// ReceivePack runs the git receive-pack protocol against the provided repo.
2049+func ReceivePack(in io.Reader, out io.Writer, er io.Writer, repoDir string) error {
2050+	if err := ensureRepo(repoDir, ""); err != nil {
2051+		return err
2052+	}
2053+	if err := RunGit(in, out, er, "", ReceivePackBin[4:], repoDir); err != nil {
2054+		return err
2055+	}
2056+	return ensureDefaultBranch(in, out, er, repoDir)
2057+}
2058+
2059+// RunGit runs a git command in the given repo.
2060+func RunGit(in io.Reader, out io.Writer, err io.Writer, dir string, args ...string) error {
2061+	c := git.NewCommand(args...)
2062+	return c.RunInDirWithOptions(dir, git.RunInDirOptions{
2063+		Stdin:  in,
2064+		Stdout: out,
2065+		Stderr: err,
2066+	})
2067+}
2068+
2069+// WritePktline encodes and writes a pktline to the given writer.
2070+func WritePktline(w io.Writer, v ...interface{}) {
2071+	msg := fmt.Sprintln(v...)
2072+	pkt := pktline.NewEncoder(w)
2073+	if err := pkt.EncodeString(msg); err != nil {
2074+		log.Printf("git: error writing pkt-line message: %s", err)
2075+	}
2076+	if err := pkt.Flush(); err != nil {
2077+		log.Printf("git: error flushing pkt-line message: %s", err)
2078+	}
2079+}
2080+
2081diff --git a/server/git/daemon/conn.go b/server/git/daemon/conn.go
2082new file mode 100644
2083index 0000000000000000000000000000000000000000..1ab35242405bc4c2cad2673eec3091cead55213f
2084--- /dev/null
2085+++ b/server/git/daemon/conn.go
2086@@ -0,0 +1,55 @@
2087+package daemon
2088+
2089+import (
2090+	"context"
2091+	"net"
2092+	"time"
2093+)
2094+
2095+type serverConn struct {
2096+	net.Conn
2097+
2098+	idleTimeout   time.Duration
2099+	maxDeadline   time.Time
2100+	closeCanceler context.CancelFunc
2101+}
2102+
2103+func (c *serverConn) Write(p []byte) (n int, err error) {
2104+	c.updateDeadline()
2105+	n, err = c.Conn.Write(p)
2106+	if _, isNetErr := err.(net.Error); isNetErr && c.closeCanceler != nil {
2107+		c.closeCanceler()
2108+	}
2109+	return
2110+}
2111+
2112+func (c *serverConn) Read(b []byte) (n int, err error) {
2113+	c.updateDeadline()
2114+	n, err = c.Conn.Read(b)
2115+	if _, isNetErr := err.(net.Error); isNetErr && c.closeCanceler != nil {
2116+		c.closeCanceler()
2117+	}
2118+	return
2119+}
2120+
2121+func (c *serverConn) Close() (err error) {
2122+	err = c.Conn.Close()
2123+	if c.closeCanceler != nil {
2124+		c.closeCanceler()
2125+	}
2126+	return
2127+}
2128+
2129+func (c *serverConn) updateDeadline() {
2130+	switch {
2131+	case c.idleTimeout > 0:
2132+		idleDeadline := time.Now().Add(c.idleTimeout)
2133+		if idleDeadline.Unix() < c.maxDeadline.Unix() || c.maxDeadline.IsZero() {
2134+			c.Conn.SetDeadline(idleDeadline)
2135+			return
2136+		}
2137+		fallthrough
2138+	default:
2139+		c.Conn.SetDeadline(c.maxDeadline)
2140+	}
2141+}
2142diff --git a/server/middleware_test.go b/server/middleware_test.go
2143deleted file mode 100644
2144index 6895d3d10d9483fb0b202973a84e388a5f8a6bc8..0000000000000000000000000000000000000000
2145--- a/server/middleware_test.go
2146+++ /dev/null
2147@@ -1,38 +0,0 @@
2148-package server
2149-
2150-import (
2151-	"os"
2152-	"testing"
2153-
2154-	"github.com/charmbracelet/soft-serve/config"
2155-	sconfig "github.com/charmbracelet/soft-serve/server/config"
2156-	"github.com/charmbracelet/wish/testsession"
2157-	"github.com/gliderlabs/ssh"
2158-	"github.com/matryer/is"
2159-)
2160-
2161-var ()
2162-
2163-func TestMiddleware(t *testing.T) {
2164-	t.Cleanup(func() {
2165-		os.RemoveAll("testmiddleware")
2166-	})
2167-	is := is.New(t)
2168-	appCfg, err := config.NewConfig(&sconfig.Config{
2169-		Host:     "localhost",
2170-		Port:     22223,
2171-		RepoPath: "testmiddleware/repos",
2172-		KeyPath:  "testmiddleware/key",
2173-	})
2174-	is.NoErr(err)
2175-	_ = testsession.New(t, &ssh.Server{
2176-		Handler: softMiddleware(appCfg)(func(s ssh.Session) {
2177-			t.Run("TestCatConfig", func(t *testing.T) {
2178-				_, err := s.Write([]byte("cat config/config.json"))
2179-				if err == nil {
2180-					t.Errorf("Expected error, got nil")
2181-				}
2182-			})
2183-		}),
2184-	}, nil)
2185-}
2186diff --git a/server/server.go b/server/server.go
2187index 1ba07f7f8887a10a06a43b4c31356a6f9e0b4b6a..b52d09f28c08f5c71f3621055f0f2166307cc1ca 100644
2188--- a/server/server.go
2189+++ b/server/server.go
2190@@ -2,29 +2,26 @@ package server
2191 
2192 import (
2193 	"context"
2194-	"fmt"
2195-	"net"
2196-	"path/filepath"
2197-	"strings"
2198 
2199 	"github.com/charmbracelet/log"
2200 
2201-	appCfg "github.com/charmbracelet/soft-serve/config"
2202+	"github.com/charmbracelet/soft-serve/server/backend"
2203 	"github.com/charmbracelet/soft-serve/server/config"
2204-	"github.com/charmbracelet/wish"
2205-	bm "github.com/charmbracelet/wish/bubbletea"
2206-	gm "github.com/charmbracelet/wish/git"
2207-	lm "github.com/charmbracelet/wish/logging"
2208-	rm "github.com/charmbracelet/wish/recover"
2209-	"github.com/gliderlabs/ssh"
2210-	"github.com/muesli/termenv"
2211+	"github.com/charmbracelet/ssh"
2212+	"golang.org/x/sync/errgroup"
2213+)
2214+
2215+var (
2216+	logger = log.WithPrefix("server")
2217 )
2218 
2219 // Server is the Soft Serve server.
2220 type Server struct {
2221-	SSHServer *ssh.Server
2222+	SSHServer *SSHServer
2223+	GitDaemon *GitDaemon
2224 	Config    *config.Config
2225-	config    *appCfg.Config
2226+	Backend   backend.Backend
2227+	Access    backend.AccessMethod
2228 }
2229 
2230 // NewServer returns a new *ssh.Server configured to serve Soft Serve. The SSH
2231@@ -32,85 +29,66 @@ type Server struct {
2232 // key can be provided with authKey. If authKey is provided, access will be
2233 // restricted to that key. If authKey is not provided, the server will be
2234 // publicly writable until configured otherwise by cloning the `config` repo.
2235-func NewServer(cfg *config.Config) *Server {
2236-	ac, err := appCfg.NewConfig(cfg)
2237-	if err != nil {
2238-		log.Fatal(err)
2239+func NewServer(cfg *config.Config) (*Server, error) {
2240+	var err error
2241+	srv := &Server{
2242+		Config:  cfg,
2243+		Backend: cfg.Backend,
2244+		Access:  cfg.Access,
2245 	}
2246-	mw := []wish.Middleware{
2247-		rm.MiddlewareWithLogger(
2248-			cfg.ErrorLog,
2249-			softMiddleware(ac),
2250-			bm.MiddlewareWithProgramHandler(SessionHandler(ac), termenv.ANSI256),
2251-			gm.Middleware(cfg.RepoPath, ac),
2252-			// Note: disable pushing to subdirectories as it can create
2253-			// conflicts with existing repos. This only affects the git
2254-			// middleware.
2255-			//
2256-			// This is related to
2257-			// https://github.com/charmbracelet/soft-serve/issues/120
2258-			// https://github.com/charmbracelet/wish/commit/8808de520d3ea21931f13113c6b0b6d0141272d4
2259-			func(sh ssh.Handler) ssh.Handler {
2260-				return func(s ssh.Session) {
2261-					cmds := s.Command()
2262-					if len(cmds) == 2 && strings.HasPrefix(cmds[0], "git") {
2263-						repo := strings.TrimSuffix(strings.TrimPrefix(cmds[1], "/"), "/")
2264-						repo = filepath.Clean(repo)
2265-						if n := strings.Count(repo, "/"); n != 0 {
2266-							wish.Fatalln(s, fmt.Errorf("invalid repo path: subdirectories not allowed"))
2267-							return
2268-						}
2269-					}
2270-					sh(s)
2271-				}
2272-			},
2273-			lm.MiddlewareWithLogger(log.StandardLog(log.StandardLogOptions{ForceLevel: log.DebugLevel})),
2274-		),
2275-	}
2276-	s, err := wish.NewServer(
2277-		ssh.PublicKeyAuth(ac.PublicKeyHandler),
2278-		ssh.KeyboardInteractiveAuth(ac.KeyboardInteractiveHandler),
2279-		wish.WithAddress(fmt.Sprintf("%s:%d", cfg.BindAddr, cfg.Port)),
2280-		wish.WithHostKeyPath(cfg.KeyPath),
2281-		wish.WithMiddleware(mw...),
2282-	)
2283+	srv.SSHServer, err = NewSSHServer(cfg)
2284 	if err != nil {
2285-		log.Fatal(err)
2286+		return nil, err
2287 	}
2288-	return &Server{
2289-		SSHServer: s,
2290diff --git a/server/server_test.go b/server/server_test.go
2291index 6f126ce9bbd81b60f608d936f05996d57914339f..b3bdb9ebbafb89ac7987fb2744c01e3f4c41896b 100644
2292--- a/server/server_test.go
2293+++ b/server/server_test.go
2294@@ -2,116 +2,64 @@ package server
2295 
2296 import (
2297 	"fmt"
2298-	"os"
2299+	"net"
2300 	"path/filepath"
2301+	"strings"
2302 	"testing"
2303 
2304 	"github.com/charmbracelet/keygen"
2305+	"github.com/charmbracelet/soft-serve/server/backend/noop"
2306 	"github.com/charmbracelet/soft-serve/server/config"
2307-	"github.com/gliderlabs/ssh"
2308-	"github.com/go-git/go-git/v5"
2309-	gconfig "github.com/go-git/go-git/v5/config"
2310-	"github.com/go-git/go-git/v5/plumbing/object"
2311-	gssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
2312+	"github.com/charmbracelet/ssh"
2313 	"github.com/matryer/is"
2314-	cssh "golang.org/x/crypto/ssh"
2315+	gossh "golang.org/x/crypto/ssh"
2316 )
2317 
2318-var (
2319-	testdata = "testdata"
2320-	cfg      = &config.Config{
2321-		BindAddr: "",
2322-		Host:     "localhost",
2323-		Port:     22222,
2324-		RepoPath: fmt.Sprintf("%s/repos", testdata),
2325-		KeyPath:  fmt.Sprintf("%s/key", testdata),
2326-	}
2327-	pkPath = ""
2328-)
2329-
2330-func TestServer(t *testing.T) {
2331-	t.Cleanup(func() {
2332-		os.RemoveAll(testdata)
2333-	})
2334-	is := is.New(t)
2335-	_, pkPath = createKeyPair(t)
2336-	s := setupServer(t)
2337-	err := s.Reload()
2338-	is.NoErr(err)
2339-	t.Run("TestPushRepo", testPushRepo)
2340-	t.Run("TestCloneRepo", testCloneRepo)
2341-}
2342-
2343-func testPushRepo(t *testing.T) {
2344-	is := is.New(t)
2345-	rp := t.TempDir()
2346-	r, err := git.PlainInit(rp, false)
2347-	is.NoErr(err)
2348-	wt, err := r.Worktree()
2349-	is.NoErr(err)
2350-	_, err = wt.Filesystem.Create("testfile")
2351-	is.NoErr(err)
2352-	_, err = wt.Add("testfile")
2353-	is.NoErr(err)
2354-	author := &object.Signature{
2355-		Name:  "test",
2356-		Email: "",
2357-	}
2358-	_, err = wt.Commit("test commit", &git.CommitOptions{
2359-		All:       true,
2360-		Author:    author,
2361-		Committer: author,
2362-	})
2363-	is.NoErr(err)
2364-	_, err = r.CreateRemote(&gconfig.RemoteConfig{
2365-		Name: "origin",
2366-		URLs: []string{fmt.Sprintf("ssh://%s:%d/%s", cfg.Host, cfg.Port, "testrepo")},
2367-	})
2368-	auth, err := gssh.NewPublicKeysFromFile("git", pkPath, "")
2369-	is.NoErr(err)
2370-	auth.HostKeyCallbackHelper = gssh.HostKeyCallbackHelper{
2371-		HostKeyCallback: cssh.InsecureIgnoreHostKey(),
2372-	}
2373-	err = r.Push(&git.PushOptions{
2374-		RemoteName: "origin",
2375-		Auth:       auth,
2376-	})
2377-	is.NoErr(err)
2378+func randomPort() int {
2379+	addr, _ := net.Listen("tcp", ":0") //nolint:gosec
2380+	_ = addr.Close()
2381+	return addr.Addr().(*net.TCPAddr).Port
2382 }
2383 
2384-func testCloneRepo(t *testing.T) {
2385-	is := is.New(t)
2386-	auth, err := gssh.NewPublicKeysFromFile("git", pkPath, "")
2387-	is.NoErr(err)
2388-	auth.HostKeyCallbackHelper = gssh.HostKeyCallbackHelper{
2389-		HostKeyCallback: cssh.InsecureIgnoreHostKey(),
2390+func setupServer(tb testing.TB) (*Server, *config.Config, string) {
2391+	tb.Helper()
2392+	tb.Log("creating keypair")
2393+	pub, pkPath := createKeyPair(tb)
2394diff --git a/server/session.go b/server/session.go
2395index a645c09c97948ba19ddcd231e427b241f2b44875..56bc02ed88ef87d9aa1474dcc99435627e0f4b20 100644
2396--- a/server/session.go
2397+++ b/server/session.go
2398@@ -5,21 +5,18 @@ import (
2399 
2400 	"github.com/aymanbagabas/go-osc52"
2401 	tea "github.com/charmbracelet/bubbletea"
2402-	appCfg "github.com/charmbracelet/soft-serve/config"
2403+	"github.com/charmbracelet/soft-serve/server/backend"
2404 	cm "github.com/charmbracelet/soft-serve/server/cmd"
2405+	"github.com/charmbracelet/soft-serve/server/config"
2406 	"github.com/charmbracelet/soft-serve/ui"
2407 	"github.com/charmbracelet/soft-serve/ui/common"
2408-	"github.com/charmbracelet/soft-serve/ui/keymap"
2409-	"github.com/charmbracelet/soft-serve/ui/styles"
2410+	"github.com/charmbracelet/ssh"
2411 	"github.com/charmbracelet/wish"
2412 	bm "github.com/charmbracelet/wish/bubbletea"
2413-	gm "github.com/charmbracelet/wish/git"
2414-	"github.com/gliderlabs/ssh"
2415-	zone "github.com/lrstanley/bubblezone"
2416 )
2417 
2418 // SessionHandler is the soft-serve bubbletea ssh session handler.
2419-func SessionHandler(ac *appCfg.Config) bm.ProgramHandler {
2420+func SessionHandler(cfg *config.Config) bm.ProgramHandler {
2421 	return func(s ssh.Session) *tea.Program {
2422 		pty, _, active := s.Pty()
2423 		if !active {
2424@@ -29,32 +26,18 @@ func SessionHandler(ac *appCfg.Config) bm.ProgramHandler {
2425 		initialRepo := ""
2426 		if len(cmd) == 1 {
2427 			initialRepo = cmd[0]
2428-			auth := ac.AuthRepo(initialRepo, s.PublicKey())
2429-			if auth < gm.ReadOnlyAccess {
2430+			auth := cfg.Access.AccessLevel(initialRepo, s.PublicKey())
2431+			if auth < backend.ReadOnlyAccess {
2432 				wish.Fatalln(s, cm.ErrUnauthorized)
2433 				return nil
2434 			}
2435 		}
2436-		if ac.Cfg.Callbacks != nil {
2437-			ac.Cfg.Callbacks.Tui("new session")
2438-		}
2439 		envs := s.Environ()
2440 		envs = append(envs, fmt.Sprintf("TERM=%s", pty.Term))
2441 		output := osc52.NewOutput(s, envs)
2442-		c := common.Common{
2443-			Copy:   output,
2444-			Styles: styles.DefaultStyles(),
2445-			KeyMap: keymap.DefaultKeyMap(),
2446-			Width:  pty.Window.Width,
2447-			Height: pty.Window.Height,
2448-			Zone:   zone.New(),
2449-		}
2450-		m := ui.New(
2451-			ac,
2452-			s,
2453-			c,
2454-			initialRepo,
2455-		)
2456+		c := common.NewCommon(s.Context(), output, pty.Window.Width, pty.Window.Height)
2457+		c.SetValue(common.ConfigKey, cfg)
2458+		m := ui.New(c, initialRepo)
2459 		p := tea.NewProgram(m,
2460 			tea.WithInput(s),
2461 			tea.WithOutput(s),
2462diff --git a/server/session_test.go b/server/session_test.go
2463index 324402e653ec885ed2aca08ee475c54c463fed19..bd81a2320ba8fa14c2f5712b4154a8e6aa98ed76 100644
2464--- a/server/session_test.go
2465+++ b/server/session_test.go
2466@@ -1,19 +1,16 @@
2467 package server
2468 
2469 import (
2470-	"bytes"
2471 	"errors"
2472+	"fmt"
2473 	"os"
2474-	"strings"
2475 	"testing"
2476 	"time"
2477 
2478-	appCfg "github.com/charmbracelet/soft-serve/config"
2479-	cm "github.com/charmbracelet/soft-serve/server/cmd"
2480 	"github.com/charmbracelet/soft-serve/server/config"
2481+	"github.com/charmbracelet/ssh"
2482 	bm "github.com/charmbracelet/wish/bubbletea"
2483 	"github.com/charmbracelet/wish/testsession"
2484-	"github.com/gliderlabs/ssh"
2485 	"github.com/matryer/is"
2486 	"github.com/muesli/termenv"
2487 	gossh "golang.org/x/crypto/ssh"
2488@@ -21,53 +18,40 @@ import (
2489 
2490 func TestSession(t *testing.T) {
2491 	is := is.New(t)
2492-	t.Run("unauthorized repo access", func(t *testing.T) {
2493-		var out bytes.Buffer
2494-		s := setup(t)
2495-		s.Stderr = &out
2496-		defer s.Close()
2497-		err := s.RequestPty("xterm", 80, 40, nil)
2498-		is.NoErr(err)
2499-		err = s.Run("config")
2500-		// Session writes error and exits
2501-		is.True(strings.Contains(out.String(), cm.ErrUnauthorized.Error()))
2502-		var ee *gossh.ExitError
2503-		is.True(errors.As(err, &ee) && ee.ExitStatus() == 1)
2504-	})
2505 	t.Run("authorized repo access", func(t *testing.T) {
2506 		s := setup(t)
2507 		s.Stderr = os.Stderr
2508 		defer s.Close()
2509 		err := s.RequestPty("xterm", 80, 40, nil)
2510 		is.NoErr(err)
2511-		in, err := s.StdinPipe()
2512-		is.NoErr(err)
2513 		go func() {
2514-			<-time.After(time.Second)
2515-			// Send "q" to exit the config command
2516-			in.Write([]byte("q"))
2517+			time.Sleep(1 * time.Second)
2518+			s.Signal(gossh.SIGTERM)
2519+			// FIXME: exit with code 0 instead of forcibly closing the session
2520+			s.Close()
2521 		}()
2522-		err = s.Shell()
2523-		is.NoErr(err)
2524+		err = s.Run("test")
2525+		var ee *gossh.ExitMissingError
2526+		is.True(errors.As(err, &ee))
2527 	})
2528 }
2529 
2530 func setup(tb testing.TB) *gossh.Session {
2531-	is := is.New(tb)
2532 	tb.Helper()
2533-	cfg.RepoPath = tb.TempDir()
2534-	ac, err := appCfg.NewConfig(&config.Config{
2535-		Port:     22226,
2536-		KeyPath:  tb.TempDir(),
2537-		RepoPath: tb.TempDir(),
2538-		InitialAdminKeys: []string{
2539-			"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMJlb/qf2B2kMNdBxfpCQqI2ctPcsOkdZGVh5zTRhKtH",
2540-		},
2541+	is := is.New(tb)
2542+	dp := tb.TempDir()
2543+	is.NoErr(os.Setenv("SOFT_SERVE_DATA_PATH", dp))
2544+	is.NoErr(os.Setenv("SOFT_SERVE_GIT_LISTEN_ADDR", ":9418"))
2545+	is.NoErr(os.Setenv("SOFT_SERVE_SSH_LISTEN_ADDR", fmt.Sprintf(":%d", randomPort())))
2546+	tb.Cleanup(func() {
2547+		is.NoErr(os.Unsetenv("SOFT_SERVE_DATA_PATH"))
2548+		is.NoErr(os.Unsetenv("SOFT_SERVE_GIT_LISTEN_ADDR"))
2549+		is.NoErr(os.Unsetenv("SOFT_SERVE_SSH_LISTEN_ADDR"))
2550+		is.NoErr(os.RemoveAll(dp))
2551 	})
2552-	ac.AnonAccess = "read-only"
2553-	is.NoErr(err)
2554+	cfg := config.DefaultConfig()
2555 	return testsession.New(tb, &ssh.Server{
2556-		Handler: bm.MiddlewareWithProgramHandler(SessionHandler(ac), termenv.ANSI256)(func(s ssh.Session) {
2557+		Handler: bm.MiddlewareWithProgramHandler(SessionHandler(cfg), termenv.ANSI256)(func(s ssh.Session) {
2558 			_, _, active := s.Pty()
2559 			tb.Logf("PTY active %v", active)
2560 			tb.Log(s.Command())
2561diff --git a/server/ssh.go b/server/ssh.go
2562new file mode 100644
2563index 0000000000000000000000000000000000000000..1c73928255e1ef19c9f9b70b87438669d5ef4a20
2564--- /dev/null
2565+++ b/server/ssh.go
2566@@ -0,0 +1,157 @@
2567+package server
2568+
2569+import (
2570+	"errors"
2571+	"fmt"
2572+	"path/filepath"
2573+	"strings"
2574+	"time"
2575+
2576+	"github.com/charmbracelet/log"
2577+	"github.com/charmbracelet/soft-serve/server/backend"
2578+	cm "github.com/charmbracelet/soft-serve/server/cmd"
2579+	"github.com/charmbracelet/soft-serve/server/config"
2580+	"github.com/charmbracelet/ssh"
2581+	"github.com/charmbracelet/wish"
2582+	bm "github.com/charmbracelet/wish/bubbletea"
2583+	lm "github.com/charmbracelet/wish/logging"
2584+	rm "github.com/charmbracelet/wish/recover"
2585+	"github.com/muesli/termenv"
2586+	gossh "golang.org/x/crypto/ssh"
2587+)
2588+
2589+// SSHServer is a SSH server that implements the git protocol.
2590+type SSHServer struct {
2591+	*ssh.Server
2592+	cfg *config.Config
2593+}
2594+
2595+// NewSSHServer returns a new SSHServer.
2596+func NewSSHServer(cfg *config.Config) (*SSHServer, error) {
2597+	var err error
2598+	s := &SSHServer{cfg: cfg}
2599+	logger := logger.StandardLog(log.StandardLogOptions{ForceLevel: log.DebugLevel})
2600+	mw := []wish.Middleware{
2601+		rm.MiddlewareWithLogger(
2602+			logger,
2603+			// BubbleTea middleware.
2604+			bm.MiddlewareWithProgramHandler(SessionHandler(cfg), termenv.ANSI256),
2605+			// Command middleware must come after the git middleware.
2606+			cm.Middleware(cfg),
2607+			// Git middleware.
2608+			s.Middleware(cfg),
2609+			lm.MiddlewareWithLogger(logger),
2610+		),
2611+	}
2612+	s.Server, err = wish.NewServer(
2613+		ssh.PublicKeyAuth(s.PublicKeyHandler),
2614+		ssh.KeyboardInteractiveAuth(s.KeyboardInteractiveHandler),
2615+		wish.WithAddress(cfg.SSH.ListenAddr),
2616+		wish.WithHostKeyPath(cfg.SSH.KeyPath),
2617+		wish.WithMiddleware(mw...),
2618+	)
2619+	if err != nil {
2620+		return nil, err
2621+	}
2622+
2623+	if cfg.SSH.MaxTimeout > 0 {
2624+		s.Server.MaxTimeout = time.Duration(cfg.SSH.MaxTimeout) * time.Second
2625+	}
2626+	if cfg.SSH.IdleTimeout > 0 {
2627+		s.Server.IdleTimeout = time.Duration(cfg.SSH.IdleTimeout) * time.Second
2628+	}
2629+
2630+	return s, nil
2631+}
2632+
2633+// PublicKeyAuthHandler handles public key authentication.
2634+func (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) bool {
2635+	al := s.cfg.Access.AccessLevel("", pk)
2636+	logger.Debug("publickey handler", "level", al)
2637+	return al > backend.NoAccess
2638+}
2639+
2640+// KeyboardInteractiveHandler handles keyboard interactive authentication.
2641+func (s *SSHServer) KeyboardInteractiveHandler(_ ssh.Context, _ gossh.KeyboardInteractiveChallenge) bool {
2642+	return true
2643+}
2644+
2645+// Middleware adds Git server functionality to the ssh.Server. Repos are stored
2646+// in the specified repo directory. The provided Hooks implementation will be
2647+// checked for access on a per repo basis for a ssh.Session public key.
2648+// Hooks.Push and Hooks.Fetch will be called on successful completion of
2649+// their commands.
2650+func (s *SSHServer) Middleware(cfg *config.Config) wish.Middleware {
2651+	return func(sh ssh.Handler) ssh.Handler {
2652+		return func(s ssh.Session) {
2653+			func() {
2654+				cmd := s.Command()
2655+				if len(cmd) >= 2 && strings.HasPrefix(cmd[0], "git") {
2656+					gc := cmd[0]
2657+					// repo should be in the form of "repo.git"
2658+					repo := sanitizeRepoName(cmd[1])
2659+					name := repo
2660+					if strings.Contains(repo, "/") {
2661+						log.Printf("invalid repo: %s", repo)
2662+						sshFatal(s, fmt.Errorf("%s: %s", ErrInvalidRepo, "user repos not supported"))
2663+						return
2664+					}
2665+					pk := s.PublicKey()
2666diff --git a/ui/common/common.go b/ui/common/common.go
2667index 8a5cb451a92824ba6289b438c0b03919bfbeba0a..660b6fdabba2f703fb0a22ea2dfcd13fd85528b7 100644
2668--- a/ui/common/common.go
2669+++ b/ui/common/common.go
2670@@ -1,20 +1,56 @@
2671 package common
2672 
2673 import (
2674+	"context"
2675+
2676 	"github.com/aymanbagabas/go-osc52"
2677+	"github.com/charmbracelet/soft-serve/git"
2678+	"github.com/charmbracelet/soft-serve/server/config"
2679 	"github.com/charmbracelet/soft-serve/ui/keymap"
2680 	"github.com/charmbracelet/soft-serve/ui/styles"
2681+	"github.com/charmbracelet/ssh"
2682 	zone "github.com/lrstanley/bubblezone"
2683 )
2684 
2685+type contextKey struct {
2686+	name string
2687+}
2688+
2689+// Keys to use for context.Context.
2690+var (
2691+	ConfigKey = &contextKey{"config"}
2692+	RepoKey   = &contextKey{"repo"}
2693+)
2694+
2695 // Common is a struct all components should embed.
2696 type Common struct {
2697-	Copy   *osc52.Output
2698-	Styles *styles.Styles
2699-	KeyMap *keymap.KeyMap
2700-	Width  int
2701-	Height int
2702-	Zone   *zone.Manager
2703+	ctx           context.Context
2704+	Width, Height int
2705+	Styles        *styles.Styles
2706+	KeyMap        *keymap.KeyMap
2707+	Copy          *osc52.Output
2708+	Zone          *zone.Manager
2709+}
2710+
2711+// NewCommon returns a new Common struct.
2712+func NewCommon(ctx context.Context, copy *osc52.Output, width, height int) Common {
2713+	if ctx == nil {
2714+		ctx = context.TODO()
2715+	}
2716+	return Common{
2717+		ctx:    ctx,
2718+		Width:  width,
2719+		Height: height,
2720+		Copy:   copy,
2721+		Styles: styles.DefaultStyles(),
2722+		KeyMap: keymap.DefaultKeyMap(),
2723+		Zone:   zone.New(),
2724+	}
2725+}
2726+
2727+// SetValue sets a value in the context.
2728+func (c *Common) SetValue(key, value interface{}) {
2729+	c.ctx = context.WithValue(c.ctx, key, value)
2730 }
2731 
2732 // SetSize sets the width and height of the common struct.
2733@@ -22,3 +58,30 @@ func (c *Common) SetSize(width, height int) {
2734 	c.Width = width
2735 	c.Height = height
2736 }
2737+
2738+// Config returns the server config.
2739+func (c *Common) Config() *config.Config {
2740+	v := c.ctx.Value(ConfigKey)
2741+	if cfg, ok := v.(*config.Config); ok {
2742+		return cfg
2743+	}
2744+	return nil
2745+}
2746+
2747+// Repo returns the repository.
2748+func (c *Common) Repo() *git.Repository {
2749+	v := c.ctx.Value(RepoKey)
2750+	if r, ok := v.(*git.Repository); ok {
2751+		return r
2752+	}
2753+	return nil
2754+}
2755+
2756+// PublicKey returns the public key.
2757+func (c *Common) PublicKey() ssh.PublicKey {
2758+	v := c.ctx.Value(ssh.ContextKeyPublicKey)
2759+	if p, ok := v.(ssh.PublicKey); ok {
2760+		return p
2761+	}
2762+	return nil
2763+}
2764diff --git a/ui/common/error.go b/ui/common/error.go
2765index fe972980562270ccfb0c8f854e3a3360b4389bc7..753f5f26f77fc778157c2dc5d3c7b06440a29b8e 100644
2766--- a/ui/common/error.go
2767+++ b/ui/common/error.go
2768@@ -1,6 +1,13 @@
2769 package common
2770 
2771-import tea "github.com/charmbracelet/bubbletea"
2772+import (
2773+	"errors"
2774+
2775+	tea "github.com/charmbracelet/bubbletea"
2776+)
2777+
2778+// ErrMissingRepo indicates that the requested repository could not be found.
2779+var ErrMissingRepo = errors.New("missing repo")
2780 
2781 // ErrorMsg is a Bubble Tea message that represents an error.
2782 type ErrorMsg error
2783diff --git a/ui/common/utils.go b/ui/common/utils.go
2784index 7c817a50ebcb6b92b04006837bde1bbd3ae732b0..de8fbe8a6316795c5f581fae471f3287122d1894 100644
2785--- a/ui/common/utils.go
2786+++ b/ui/common/utils.go
2787@@ -1,6 +1,10 @@
2788 package common
2789 
2790-import "github.com/muesli/reflow/truncate"
2791+import (
2792+	"fmt"
2793+
2794+	"github.com/muesli/reflow/truncate"
2795+)
2796 
2797 // TruncateString is a convenient wrapper around truncate.TruncateString.
2798 func TruncateString(s string, max int) string {
2799@@ -9,3 +13,12 @@ func TruncateString(s string, max int) string {
2800 	}
2801 	return truncate.StringWithTail(s, uint(max), "…")
2802 }
2803+
2804+// RepoURL returns the URL of the repository.
2805+func RepoURL(host string, port string, name string) string {
2806+	p := ""
2807+	if port != "22" {
2808+		p += ":" + port
2809+	}
2810+	return fmt.Sprintf("git clone ssh://%s/%s", host+p, name)
2811+}
2812diff --git a/ui/components/code/code.go b/ui/components/code/code.go
2813index 9c832bc836b7f1437a7488b86ca6f55d95af241a..8b442db8209023b34cc2167f43477ef0a16a2286 100644
2814--- a/ui/components/code/code.go
2815+++ b/ui/components/code/code.go
2816@@ -47,7 +47,7 @@ func New(c common.Common, content, extension string) *Code {
2817 		content:        content,
2818 		extension:      extension,
2819 		Viewport:       vp.New(c),
2820-		NoContentStyle: c.Styles.CodeNoContent.Copy(),
2821+		NoContentStyle: c.Styles.NoContent.Copy(),
2822 		LineDigitStyle: lineDigitStyle,
2823 		LineBarStyle:   lineBarStyle,
2824 	}
2825diff --git a/ui/git.go b/ui/git.go
2826deleted file mode 100644
2827index e4dcf80a2453d0f946ae09ad5dbd7ba71eb53571..0000000000000000000000000000000000000000
2828--- a/ui/git.go
2829+++ /dev/null
2830@@ -1,25 +0,0 @@
2831-package ui
2832-
2833-import (
2834-	"github.com/charmbracelet/soft-serve/config"
2835-	"github.com/charmbracelet/soft-serve/ui/git"
2836-)
2837-
2838-// source is a wrapper around config.RepoSource that implements git.GitRepoSource.
2839-type source struct {
2840-	*config.RepoSource
2841-}
2842-
2843-// GetRepo implements git.GitRepoSource.
2844-func (s *source) GetRepo(name string) (git.GitRepo, error) {
2845-	return s.RepoSource.GetRepo(name)
2846-}
2847-
2848-// AllRepos implements git.GitRepoSource.
2849-func (s *source) AllRepos() []git.GitRepo {
2850-	rs := make([]git.GitRepo, 0)
2851-	for _, r := range s.RepoSource.AllRepos() {
2852-		rs = append(rs, r)
2853-	}
2854-	return rs
2855-}
2856diff --git a/ui/git/git.go b/ui/git/git.go
2857deleted file mode 100644
2858index c51fee1cddf57cf602985154c117385552a1d752..0000000000000000000000000000000000000000
2859--- a/ui/git/git.go
2860+++ /dev/null
2861@@ -1,42 +0,0 @@
2862-package git
2863-
2864-import (
2865-	"errors"
2866-	"fmt"
2867-
2868-	"github.com/charmbracelet/soft-serve/git"
2869-)
2870-
2871-// ErrMissingRepo indicates that the requested repository could not be found.
2872-var ErrMissingRepo = errors.New("missing repo")
2873-
2874-// GitRepo is an interface for Git repositories.
2875-type GitRepo interface {
2876-	Repo() string
2877-	Name() string
2878-	Description() string
2879-	Readme() (string, string)
2880-	HEAD() (*git.Reference, error)
2881-	Commit(string) (*git.Commit, error)
2882-	CommitsByPage(*git.Reference, int, int) (git.Commits, error)
2883-	CountCommits(*git.Reference) (int64, error)
2884-	Diff(*git.Commit) (*git.Diff, error)
2885-	References() ([]*git.Reference, error)
2886-	Tree(*git.Reference, string) (*git.Tree, error)
2887-	IsPrivate() bool
2888-}
2889-
2890-// GitRepoSource is an interface for Git repository factory.
2891-type GitRepoSource interface {
2892-	GetRepo(string) (GitRepo, error)
2893-	AllRepos() []GitRepo
2894-}
2895-
2896-// RepoURL returns the URL of the repository.
2897-func RepoURL(host string, port int, name string) string {
2898-	p := ""
2899-	if port != 22 {
2900-		p += fmt.Sprintf(":%d", port)
2901-	}
2902-	return fmt.Sprintf("git clone ssh://%s/%s", host+p, name)
2903-}
2904diff --git a/ui/pages/repo/empty.go b/ui/pages/repo/empty.go
2905new file mode 100644
2906index 0000000000000000000000000000000000000000..bddab29f15fc476c9f7b974ecc877c6e294dcacc
2907--- /dev/null
2908+++ b/ui/pages/repo/empty.go
2909@@ -0,0 +1,45 @@
2910+package repo
2911+
2912+import (
2913+	"fmt"
2914+	"strings"
2915+
2916+	"github.com/charmbracelet/soft-serve/server/config"
2917+)
2918+
2919+func defaultEmptyRepoMsg(cfg *config.Config, repo string) string {
2920+	host := cfg.Backend.ServerHost()
2921+	if cfg.Backend.ServerPort() != "22" {
2922+		host = fmt.Sprintf("%s:%s", host, cfg.Backend.ServerPort())
2923+	}
2924+	repo = strings.TrimSuffix(repo, ".git")
2925+	return fmt.Sprintf(`# Quick Start
2926+
2927+Get started by cloning this repository, add your files, commit, and push.
2928+
2929+## Clone this repository.
2930+
2931+`+"```"+`sh
2932+git clone ssh://%[1]s/%[2]s.git
2933+`+"```"+`
2934+
2935+## Creating a new repository on the command line
2936+
2937+`+"```"+`sh
2938+touch README.md
2939+git init
2940+git add README.md
2941+git branch -M main
2942+git commit -m "first commit"
2943+git remote add origin ssh://%[1]s/%[2]s.git
2944+git push -u origin main
2945+`+"```"+`
2946+
2947+## Pushing an existing repository from the command line
2948+
2949+`+"```"+`sh
2950+git remote add origin ssh://%[1]s/%[2]s.git
2951+git push -u origin main
2952+`+"```"+`
2953+`, host, repo)
2954+}
2955diff --git a/ui/pages/repo/files.go b/ui/pages/repo/files.go
2956index 745016102a4c169f60381927f28f036b59167041..83f01d6ddf4063bba7a27f31b0c7fcff68ed17a9 100644
2957--- a/ui/pages/repo/files.go
2958+++ b/ui/pages/repo/files.go
2959@@ -3,16 +3,17 @@ package repo
2960 import (
2961 	"errors"
2962 	"fmt"
2963+	"log"
2964 	"path/filepath"
2965 
2966 	"github.com/alecthomas/chroma/lexers"
2967 	"github.com/charmbracelet/bubbles/key"
2968 	tea "github.com/charmbracelet/bubbletea"
2969-	ggit "github.com/charmbracelet/soft-serve/git"
2970+	"github.com/charmbracelet/soft-serve/git"
2971+	"github.com/charmbracelet/soft-serve/server/backend"
2972 	"github.com/charmbracelet/soft-serve/ui/common"
2973 	"github.com/charmbracelet/soft-serve/ui/components/code"
2974 	"github.com/charmbracelet/soft-serve/ui/components/selector"
2975-	"github.com/charmbracelet/soft-serve/ui/git"
2976 )
2977 
2978 type filesView int
2979@@ -49,9 +50,9 @@ type FileContentMsg struct {
2980 type Files struct {
2981 	common         common.Common
2982 	selector       *selector.Selector
2983-	ref            *ggit.Reference
2984+	ref            *git.Reference
2985 	activeView     filesView
2986-	repo           git.GitRepo
2987+	repo           backend.Repository
2988 	code           *code.Code
2989 	path           string
2990 	currentItem    *FileItem
2991@@ -200,8 +201,7 @@ func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
2992 	cmds := make([]tea.Cmd, 0)
2993 	switch msg := msg.(type) {
2994 	case RepoMsg:
2995-		f.repo = git.GitRepo(msg)
2996-		cmds = append(cmds, f.Init())
2997+		f.repo = msg
2998 	case RefMsg:
2999 		f.ref = msg
3000 		cmds = append(cmds, f.Init())
3001@@ -265,6 +265,14 @@ func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
3002 				}
3003 			}
3004 		}
3005+	case EmptyRepoMsg:
3006+		f.ref = nil
3007+		f.path = ""
3008+		f.currentItem = nil
3009+		f.activeView = filesViewFiles
3010+		f.lastSelected = make([]int, 0)
3011+		f.selector.Select(0)
3012+		cmds = append(cmds, f.setItems([]selector.IdentifiableItem{}))
3013 	}
3014 	switch f.activeView {
3015 	case filesViewFiles:
3016@@ -320,14 +328,21 @@ func (f *Files) updateFilesCmd() tea.Msg {
3017 	files := make([]selector.IdentifiableItem, 0)
3018 	dirs := make([]selector.IdentifiableItem, 0)
3019 	if f.ref == nil {
3020+		log.Printf("ui: files: ref is nil")
3021 		return common.ErrorMsg(errNoRef)
3022 	}
3023-	t, err := f.repo.Tree(f.ref, f.path)
3024+	r, err := f.repo.Repository()
3025+	if err != nil {
3026+		return common.ErrorMsg(err)
3027+	}
3028+	t, err := r.TreePath(f.ref, f.path)
3029 	if err != nil {
3030+		log.Printf("ui: files: error getting tree %v", err)
3031 		return common.ErrorMsg(err)
3032 	}
3033 	ents, err := t.Entries()
3034 	if err != nil {
3035+		log.Printf("ui: files: error listing files %v", err)
3036 		return common.ErrorMsg(err)
3037 	}
3038 	ents.Sort()
3039@@ -347,6 +362,7 @@ func (f *Files) selectTreeCmd() tea.Msg {
3040 		f.selector.Select(0)
3041 		return f.updateFilesCmd()
3042 	}
3043+	log.Printf("ui: files: current item is not a tree")
3044 	return common.ErrorMsg(errNoFileSelected)
3045 }
3046 
3047@@ -355,25 +371,30 @@ func (f *Files) selectFileCmd() tea.Msg {
3048 	if i != nil && !i.entry.IsTree() {
3049 		fi := i.entry.File()
3050 		if i.Mode().IsDir() || f == nil {
3051+			log.Printf("ui: files: current item is not a file")
3052 			return common.ErrorMsg(errInvalidFile)
3053 		}
3054 		bin, err := fi.IsBinary()
3055 		if err != nil {
3056 			f.path = filepath.Dir(f.path)
3057+			log.Printf("ui: files: error checking if file is binary %v", err)
3058 			return common.ErrorMsg(err)
3059diff --git a/ui/pages/repo/log.go b/ui/pages/repo/log.go
3060index a1511a5ff37db765010d83757d5ac92808d4c164..23bc2388060ccc66752d155773415ff0a8a4e3ff 100644
3061--- a/ui/pages/repo/log.go
3062+++ b/ui/pages/repo/log.go
3063@@ -2,6 +2,7 @@ package repo
3064 
3065 import (
3066 	"fmt"
3067+	"log"
3068 	"strings"
3069 	"time"
3070 
3071@@ -10,12 +11,12 @@ import (
3072 	tea "github.com/charmbracelet/bubbletea"
3073 	gansi "github.com/charmbracelet/glamour/ansi"
3074 	"github.com/charmbracelet/lipgloss"
3075-	ggit "github.com/charmbracelet/soft-serve/git"
3076+	"github.com/charmbracelet/soft-serve/git"
3077+	"github.com/charmbracelet/soft-serve/server/backend"
3078 	"github.com/charmbracelet/soft-serve/ui/common"
3079 	"github.com/charmbracelet/soft-serve/ui/components/footer"
3080 	"github.com/charmbracelet/soft-serve/ui/components/selector"
3081 	"github.com/charmbracelet/soft-serve/ui/components/viewport"
3082-	"github.com/charmbracelet/soft-serve/ui/git"
3083 	"github.com/muesli/reflow/wrap"
3084 	"github.com/muesli/termenv"
3085 )
3086@@ -36,10 +37,10 @@ type LogCountMsg int64
3087 type LogItemsMsg []selector.IdentifiableItem
3088 
3089 // LogCommitMsg is a message that contains a git commit.
3090-type LogCommitMsg *ggit.Commit
3091+type LogCommitMsg *git.Commit
3092 
3093 // LogDiffMsg is a message that contains a git diff.
3094-type LogDiffMsg *ggit.Diff
3095+type LogDiffMsg *git.Diff
3096 
3097 // Log is a model that displays a list of commits and their diffs.
3098 type Log struct {
3099@@ -47,13 +48,13 @@ type Log struct {
3100 	selector       *selector.Selector
3101 	vp             *viewport.Viewport
3102 	activeView     logView
3103-	repo           git.GitRepo
3104-	ref            *ggit.Reference
3105+	repo           backend.Repository
3106+	ref            *git.Reference
3107 	count          int64
3108 	nextPage       int
3109-	activeCommit   *ggit.Commit
3110-	selectedCommit *ggit.Commit
3111-	currentDiff    *ggit.Diff
3112+	activeCommit   *git.Commit
3113+	selectedCommit *git.Commit
3114+	currentDiff    *git.Diff
3115 	loadingTime    time.Time
3116 	loading        bool
3117 	spinner        spinner.Model
3118@@ -77,9 +78,8 @@ func NewLog(common common.Common) *Log {
3119 	selector.KeyMap.NextPage = common.KeyMap.NextPage
3120 	selector.KeyMap.PrevPage = common.KeyMap.PrevPage
3121 	l.selector = selector
3122-	s := spinner.New()
3123-	s.Spinner = spinner.Dot
3124-	s.Style = common.Styles.Spinner
3125+	s := spinner.New(spinner.WithSpinner(spinner.Dot),
3126+		spinner.WithStyle(common.Styles.Spinner))
3127 	l.spinner = s
3128 	return l
3129 }
3130@@ -189,8 +189,7 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
3131 	cmds := make([]tea.Cmd, 0)
3132 	switch msg := msg.(type) {
3133 	case RepoMsg:
3134-		l.repo = git.GitRepo(msg)
3135-		cmds = append(cmds, l.Init())
3136+		l.repo = msg
3137 	case RefMsg:
3138 		l.ref = msg
3139 		cmds = append(cmds, l.Init())
3140@@ -245,6 +244,7 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
3141 		if l.activeView == logViewDiff {
3142 			l.activeView = logViewCommits
3143 			l.selectedCommit = nil
3144+			cmds = append(cmds, updateStatusBarCmd)
3145 		}
3146 	case selector.ActiveMsg:
3147 		switch sel := msg.IdentifiableItem.(type) {
3148@@ -299,6 +299,16 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
3149 				l.startLoading(),
3150 			)
3151 		}
3152+	case EmptyRepoMsg:
3153+		l.ref = nil
3154+		l.loading = false
3155+		l.activeView = logViewCommits
3156+		l.nextPage = 0
3157+		l.count = 0
3158+		l.activeCommit = nil
3159+		l.selectedCommit = nil
3160+		l.selector.Select(0)
3161+		cmds = append(cmds, l.setItems([]selector.IdentifiableItem{}))
3162 	}
3163diff --git a/ui/pages/repo/readme.go b/ui/pages/repo/readme.go
3164index 8605d320545e323fae40e2a7c0d6dd3761a03732..5374779b9a1e3560b06eaf46fdb8c357dbc851b3 100644
3165--- a/ui/pages/repo/readme.go
3166+++ b/ui/pages/repo/readme.go
3167@@ -2,22 +2,27 @@ package repo
3168 
3169 import (
3170 	"fmt"
3171+	"path/filepath"
3172 
3173 	"github.com/charmbracelet/bubbles/key"
3174 	tea "github.com/charmbracelet/bubbletea"
3175+	"github.com/charmbracelet/soft-serve/server/backend"
3176 	"github.com/charmbracelet/soft-serve/ui/common"
3177 	"github.com/charmbracelet/soft-serve/ui/components/code"
3178-	"github.com/charmbracelet/soft-serve/ui/git"
3179 )
3180 
3181-type ReadmeMsg struct{}
3182+// ReadmeMsg is a message sent when the readme is loaded.
3183+type ReadmeMsg struct {
3184+	Msg tea.Msg
3185+}
3186 
3187 // Readme is the readme component page.
3188 type Readme struct {
3189-	common common.Common
3190-	code   *code.Code
3191-	ref    RefMsg
3192-	repo   git.GitRepo
3193+	common     common.Common
3194+	code       *code.Code
3195+	ref        RefMsg
3196+	repo       backend.Repository
3197+	readmePath string
3198 }
3199 
3200 // NewReadme creates a new readme model.
3201@@ -64,15 +69,7 @@ func (r *Readme) FullHelp() [][]key.Binding {
3202 
3203 // Init implements tea.Model.
3204 func (r *Readme) Init() tea.Cmd {
3205-	if r.repo == nil {
3206-		return common.ErrorCmd(git.ErrMissingRepo)
3207-	}
3208-	rm, rp := r.repo.Readme()
3209-	r.code.GotoTop()
3210-	return tea.Batch(
3211-		r.code.SetContent(rm, rp),
3212-		r.updateReadmeCmd,
3213-	)
3214+	return r.updateReadmeCmd
3215 }
3216 
3217 // Update implements tea.Model.
3218@@ -80,11 +77,13 @@ func (r *Readme) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
3219 	cmds := make([]tea.Cmd, 0)
3220 	switch msg := msg.(type) {
3221 	case RepoMsg:
3222-		r.repo = git.GitRepo(msg)
3223-		cmds = append(cmds, r.Init())
3224+		r.repo = msg
3225 	case RefMsg:
3226 		r.ref = msg
3227 		cmds = append(cmds, r.Init())
3228+	case EmptyRepoMsg:
3229+		r.code.SetContent(defaultEmptyRepoMsg(r.common.Config(),
3230+			r.repo.Name()), ".md")
3231 	}
3232 	c, cmd := r.code.Update(msg)
3233 	r.code = c.(*code.Code)
3234@@ -101,7 +100,11 @@ func (r *Readme) View() string {
3235 
3236 // StatusBarValue implements statusbar.StatusBar.
3237 func (r *Readme) StatusBarValue() string {
3238-	return ""
3239+	dir := filepath.Dir(r.readmePath)
3240+	if dir == "." {
3241+		return ""
3242+	}
3243+	return dir
3244 }
3245 
3246 // StatusBarInfo implements statusbar.StatusBar.
3247@@ -110,5 +113,19 @@ func (r *Readme) StatusBarInfo() string {
3248 }
3249 
3250 func (r *Readme) updateReadmeCmd() tea.Msg {
3251-	return ReadmeMsg{}
3252+	m := ReadmeMsg{}
3253+	if r.repo == nil {
3254+		return common.ErrorCmd(common.ErrMissingRepo)
3255+	}
3256+	rm, rp, err := backend.Readme(r.repo)
3257+	if err != nil {
3258+		return common.ErrorCmd(err)
3259+	}
3260+	r.readmePath = rp
3261+	r.code.GotoTop()
3262+	cmd := r.code.SetContent(rm, rp)
3263+	if cmd != nil {
3264+		m.Msg = cmd()
3265+	}
3266+	return m
3267diff --git a/ui/pages/repo/refs.go b/ui/pages/repo/refs.go
3268index 308a26288372c3ecbcaa4b9b985e51075fb4136f..b0af0defeb94355d3f9314fb65cc4fba2e53c9a6 100644
3269--- a/ui/pages/repo/refs.go
3270+++ b/ui/pages/repo/refs.go
3271@@ -3,22 +3,27 @@ package repo
3272 import (
3273 	"errors"
3274 	"fmt"
3275+	"log"
3276 	"sort"
3277 	"strings"
3278 
3279 	"github.com/charmbracelet/bubbles/key"
3280 	tea "github.com/charmbracelet/bubbletea"
3281+	"github.com/charmbracelet/soft-serve/git"
3282 	ggit "github.com/charmbracelet/soft-serve/git"
3283+	"github.com/charmbracelet/soft-serve/server/backend"
3284 	"github.com/charmbracelet/soft-serve/ui/common"
3285 	"github.com/charmbracelet/soft-serve/ui/components/selector"
3286 	"github.com/charmbracelet/soft-serve/ui/components/tabs"
3287-	"github.com/charmbracelet/soft-serve/ui/git"
3288 )
3289 
3290 var (
3291 	errNoRef = errors.New("no reference specified")
3292 )
3293 
3294+// RefMsg is a message that contains a git.Reference.
3295+type RefMsg *ggit.Reference
3296+
3297 // RefItemsMsg is a message that contains a list of RefItem.
3298 type RefItemsMsg struct {
3299 	prefix string
3300@@ -29,9 +34,9 @@ type RefItemsMsg struct {
3301 type Refs struct {
3302 	common    common.Common
3303 	selector  *selector.Selector
3304-	repo      git.GitRepo
3305-	ref       *ggit.Reference
3306-	activeRef *ggit.Reference
3307+	repo      backend.Repository
3308+	ref       *git.Reference
3309+	activeRef *git.Reference
3310 	refPrefix string
3311 }
3312 
3313@@ -104,8 +109,7 @@ func (r *Refs) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
3314 	switch msg := msg.(type) {
3315 	case RepoMsg:
3316 		r.selector.Select(0)
3317-		r.repo = git.GitRepo(msg)
3318-		cmds = append(cmds, r.Init())
3319+		r.repo = msg
3320 	case RefMsg:
3321 		r.ref = msg
3322 		cmds = append(cmds, r.Init())
3323@@ -136,6 +140,9 @@ func (r *Refs) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
3324 		case key.Matches(msg, r.common.KeyMap.SelectItem):
3325 			cmds = append(cmds, r.selector.SelectItem)
3326 		}
3327+	case EmptyRepoMsg:
3328+		r.ref = nil
3329+		cmds = append(cmds, r.setItems([]selector.IdentifiableItem{}))
3330 	}
3331 	m, cmd := r.selector.Update(msg)
3332 	r.selector = m.(*selector.Selector)
3333@@ -169,8 +176,13 @@ func (r *Refs) StatusBarInfo() string {
3334 
3335 func (r *Refs) updateItemsCmd() tea.Msg {
3336 	its := make(RefItems, 0)
3337-	refs, err := r.repo.References()
3338+	rr, err := r.repo.Repository()
3339+	if err != nil {
3340+		return common.ErrorMsg(err)
3341+	}
3342+	refs, err := rr.References()
3343 	if err != nil {
3344+		log.Printf("ui: error getting references: %v", err)
3345 		return common.ErrorMsg(err)
3346 	}
3347 	for _, ref := range refs {
3348@@ -189,8 +201,37 @@ func (r *Refs) updateItemsCmd() tea.Msg {
3349 	}
3350 }
3351 
3352+func (r *Refs) setItems(items []selector.IdentifiableItem) tea.Cmd {
3353+	return func() tea.Msg {
3354+		return RefItemsMsg{
3355+			items:  items,
3356+			prefix: r.refPrefix,
3357+		}
3358+	}
3359+}
3360+
3361 func switchRefCmd(ref *ggit.Reference) tea.Cmd {
3362 	return func() tea.Msg {
3363 		return RefMsg(ref)
3364 	}
3365 }
3366+
3367+// UpdateRefCmd gets the repository's HEAD reference and sends a RefMsg.
3368+func UpdateRefCmd(repo backend.Repository) tea.Cmd {
3369+	return func() tea.Msg {
3370+		r, err := repo.Repository()