.gitignore π
@@ -1,7 +1,10 @@
cmd/soft/soft
+./soft
.ssh
.repos
dist
testdata
+data/
completions/
-manpages/
+manpages/
+soft_serve_ed25519*
Ayman Bagabas created
* feat(backend): server backend
Add file backend that uses filesystem to manage non-git operations.
* feat(git): more git commands
* refactor(config): remove config module
* feat(gomod): upgrade wish & use charmbracelet/ssh
* feat(server): use cmd middleware
* refactor(ui): clean up and tidy
* feat(git): implement git daemon
* fix(examples): update setuid
* fix(backend.file): populate default config
* fix: tests
* refactor: tidy up files
* chore(gitignore): add more files
.gitignore | 5
cmd/soft/man.go | 2
cmd/soft/serve.go | 12
config/auth.go | 155 --------
config/auth_test.go | 669 -----------------------------------
config/config.go | 334 -----------------
config/config_test.go | 37 -
config/defaults.go | 58 ---
config/git.go | 304 ---------------
config/testdata/k1.pub | 1
examples/setuid/main.go | 13
git/command.go | 11
git/config.go | 51 ++
git/errors.go | 4
git/repo.go | 40 ++
git/types.go | 9
go.mod | 24 -
go.sum | 65 ---
server/backend/file/file.go | 28 +
server/backend/noop/noop.go | 163 ++++++++
server/backend/noop/repo.go | 32 +
server/cmd/cat.go | 123 ------
server/cmd/cmd.go | 15
server/cmd/git.go | 54 --
server/cmd/list.go | 83 ----
server/cmd/middleware.go | 25
server/cmd/reload.go | 23 -
server/cmd/repo.go | 408 +++++++++++++++++++++
server/config/config.go | 93 +++-
server/daemon.go | 299 +++++++++++++++
server/daemon_test.go | 95 ++++
server/git.go | 162 ++++++++
server/git/daemon/conn.go | 55 ++
server/middleware_test.go | 38 -
server/server.go | 140 +++----
server/server_test.go | 126 +----
server/session.go | 35 -
server/session_test.go | 58 +-
server/ssh.go | 157 ++++++++
ui/common/common.go | 75 +++
ui/common/error.go | 9
ui/common/utils.go | 15
ui/components/code/code.go | 2
ui/git.go | 25 -
ui/git/git.go | 42 --
ui/pages/repo/empty.go | 45 ++
ui/pages/repo/files.go | 41 +
ui/pages/repo/log.go | 80 +++-
ui/pages/repo/readme.go | 55 +
ui/pages/repo/refs.go | 55 ++
ui/pages/repo/repo.go | 211 +++++++---
ui/pages/selection/item.go | 66 +++
ui/pages/selection/selection.go | 97 +---
ui/styles/styles.go | 18
ui/ui.go | 95 ++--
55 files changed, 2,350 insertions(+), 2,587 deletions(-)
@@ -1,7 +1,10 @@
cmd/soft/soft
+./soft
.ssh
.repos
dist
testdata
+data/
completions/
-manpages/
+manpages/
+soft_serve_ed25519*
@@ -20,7 +20,7 @@ var (
return err
}
- manPage = manPage.WithSection("Copyright", "(C) 2021-2022 Charmbracelet, Inc.\n"+
+ manPage = manPage.WithSection("Copyright", "(C) 2021-2023 Charmbracelet, Inc.\n"+
"Released under MIT license.")
fmt.Println(manPage.Build(roff.NewDocument()))
return nil
@@ -2,14 +2,12 @@ package main
import (
"context"
- "fmt"
"os"
"os/signal"
"syscall"
"time"
"github.com/charmbracelet/log"
-
"github.com/charmbracelet/soft-serve/server"
"github.com/charmbracelet/soft-serve/server/config"
"github.com/spf13/cobra"
@@ -23,9 +21,14 @@ var (
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cfg := config.DefaultConfig()
- s := server.NewServer(cfg)
+ s, err := server.NewServer(cfg)
+ if err != nil {
+ return err
+ }
- log.Print("Starting SSH server", "addr", fmt.Sprintf("%s:%d", cfg.BindAddr, cfg.Port))
+ if cfg.Debug {
+ log.SetLevel(log.DebugLevel)
+ }
done := make(chan os.Signal, 1)
lch := make(chan error, 1)
@@ -38,7 +41,6 @@ var (
signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
<-done
- log.Print("Stopping SSH server", "addr", fmt.Sprintf("%s:%d", cfg.BindAddr, cfg.Port))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := s.Shutdown(ctx); err != nil {
@@ -1,155 +0,0 @@
-package config
-
-import (
- "strings"
-
- "github.com/charmbracelet/log"
-
- gm "github.com/charmbracelet/wish/git"
- "github.com/gliderlabs/ssh"
- gossh "golang.org/x/crypto/ssh"
-)
-
-// Push registers Git push functionality for the given repo and key.
-func (cfg *Config) Push(repo string, pk ssh.PublicKey) {
- go func() {
- err := cfg.Reload()
- if err != nil {
- log.Error("error reloading after push", "err", err)
- }
- if cfg.Cfg.Callbacks != nil {
- cfg.Cfg.Callbacks.Push(repo)
- }
- r, err := cfg.Source.GetRepo(repo)
- if err != nil {
- log.Error("error getting repo after push", "err", err)
- return
- }
- err = r.UpdateServerInfo()
- if err != nil {
- log.Error("error updating server info after push", "err", err)
- }
- }()
-}
-
-// Fetch registers Git fetch functionality for the given repo and key.
-func (cfg *Config) Fetch(repo string, pk ssh.PublicKey) {
- if cfg.Cfg.Callbacks != nil {
- cfg.Cfg.Callbacks.Fetch(repo)
- }
-}
-
-// AuthRepo grants repo authorization to the given key.
-func (cfg *Config) AuthRepo(repo string, pk ssh.PublicKey) gm.AccessLevel {
- return cfg.accessForKey(repo, pk)
-}
-
-// PasswordHandler returns whether or not password access is allowed.
-func (cfg *Config) PasswordHandler(ctx ssh.Context, password string) bool {
- return (cfg.AnonAccess != "no-access") && cfg.AllowKeyless
-}
-
-// KeyboardInteractiveHandler returns whether or not keyboard interactive is allowed.
-func (cfg *Config) KeyboardInteractiveHandler(ctx ssh.Context, _ gossh.KeyboardInteractiveChallenge) bool {
- return (cfg.AnonAccess != "no-access") && cfg.AllowKeyless
-}
-
-// PublicKeyHandler returns whether or not the given public key may access the
-// repo.
-func (cfg *Config) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) bool {
- return cfg.accessForKey("", pk) != gm.NoAccess
-}
-
-func (cfg *Config) anonAccessLevel() gm.AccessLevel {
- switch cfg.AnonAccess {
- case "no-access":
- return gm.NoAccess
- case "read-only":
- return gm.ReadOnlyAccess
- case "read-write":
- return gm.ReadWriteAccess
- case "admin-access":
- return gm.AdminAccess
- default:
- return gm.NoAccess
- }
-}
-
-// accessForKey returns the access level for the given repo.
-//
-// If repo doesn't exist, then access is based on user's admin privileges, or
-// config.AnonAccess.
-// If repo exists, and private, then admins and collabs are allowed access.
-// If repo exists, and not private, then access is based on config.AnonAccess.
-func (cfg *Config) accessForKey(repo string, pk ssh.PublicKey) gm.AccessLevel {
- anon := cfg.anonAccessLevel()
- private := cfg.isPrivate(repo)
- // Find user
- for _, user := range cfg.Users {
- for _, k := range user.PublicKeys {
- apk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(strings.TrimSpace(k)))
- if err != nil {
- log.Error("malformed authorized key", "key", k)
- return gm.NoAccess
- }
- if ssh.KeysEqual(pk, apk) {
- if user.Admin {
- return gm.AdminAccess
- }
- u := user
- if cfg.isCollab(repo, &u) {
- if anon > gm.ReadWriteAccess {
- return anon
- }
- return gm.ReadWriteAccess
- }
- if !private {
- if anon > gm.ReadOnlyAccess {
- return anon
- }
- return gm.ReadOnlyAccess
- }
- }
- }
- }
- // Don't restrict access to private repos if no users are configured.
- // Return anon access level.
- if private && len(cfg.Users) > 0 {
- return gm.NoAccess
- }
- return anon
-}
-
-func (cfg *Config) findRepo(repo string) *RepoConfig {
- for _, r := range cfg.Repos {
- if r.Repo == repo {
- return &r
- }
- }
- return nil
-}
-
-func (cfg *Config) isPrivate(repo string) bool {
- if r := cfg.findRepo(repo); r != nil {
- return r.Private
- }
- return false
-}
-
-func (cfg *Config) isCollab(repo string, user *User) bool {
- if user != nil {
- for _, r := range user.CollabRepos {
- if r == repo {
- return true
- }
- }
- if r := cfg.findRepo(repo); r != nil {
- for _, c := range r.Collabs {
- if c == user.Name {
- return true
- }
- }
- }
- }
- return false
-}
@@ -1,669 +0,0 @@
-package config
-
-import (
- "testing"
-
- "github.com/charmbracelet/wish/git"
- "github.com/gliderlabs/ssh"
- "github.com/matryer/is"
-)
-
-func TestAuth(t *testing.T) {
- adminKey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH a@b"
- adminPk, _, _, _, _ := ssh.ParseAuthorizedKey([]byte(adminKey))
- dummyKey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b"
- dummyPk, _, _, _, _ := ssh.ParseAuthorizedKey([]byte(dummyKey))
- cases := []struct {
- name string
- cfg Config
- repo string
- key ssh.PublicKey
- access git.AccessLevel
- }{
- // Repo access
- {
- name: "anon access: no-access, anonymous user",
- access: git.NoAccess,
- repo: "foo",
- cfg: Config{
- AnonAccess: "no-access",
- Repos: []RepoConfig{
- {
- Repo: "foo",
- },
- },
- },
- },
- {
- name: "anon access: no-access, anonymous user with admin user",
- access: git.NoAccess,
- repo: "foo",
- cfg: Config{
- AnonAccess: "no-access",
- Repos: []RepoConfig{
- {
- Repo: "foo",
- },
- },
- Users: []User{
- {
- Admin: true,
- PublicKeys: []string{
- adminKey,
- },
- },
- },
- },
- },
- {
- name: "anon access: no-access, authd user",
- key: dummyPk,
- repo: "foo",
- access: git.ReadOnlyAccess,
- cfg: Config{
- AnonAccess: "no-access",
- Repos: []RepoConfig{
- {
- Repo: "foo",
- },
- },
- Users: []User{
- {
- PublicKeys: []string{
- dummyKey,
- },
- },
- },
- },
- },
- {
- name: "anon access: no-access, anonymous user with admin user",
- key: dummyPk,
- repo: "foo",
- access: git.NoAccess,
- cfg: Config{
- AnonAccess: "no-access",
- Repos: []RepoConfig{
- {
- Repo: "foo",
- },
- },
- Users: []User{
- {
- Admin: true,
- PublicKeys: []string{
- adminKey,
- },
- },
- },
- },
- },
- {
- name: "anon access: no-access, admin user",
- repo: "foo",
- key: adminPk,
- access: git.AdminAccess,
- cfg: Config{
- AnonAccess: "no-access",
- Repos: []RepoConfig{
- {
- Repo: "foo",
- },
- },
- Users: []User{
- {
- Admin: true,
- PublicKeys: []string{
- adminKey,
- },
- },
- },
- },
- },
- {
- name: "anon access: read-only, anonymous user",
- repo: "foo",
- access: git.ReadOnlyAccess,
- cfg: Config{
- AnonAccess: "read-only",
- Repos: []RepoConfig{
- {
- Repo: "foo",
- },
- },
- },
- },
- {
- name: "anon access: read-only, authd user",
- repo: "foo",
- key: dummyPk,
- access: git.ReadOnlyAccess,
- cfg: Config{
- AnonAccess: "read-only",
- Repos: []RepoConfig{
- {
- Repo: "foo",
- },
- },
- Users: []User{
- {
- PublicKeys: []string{
- dummyKey,
- },
- },
- },
- },
- },
- {
- name: "anon access: read-only, admin user",
- repo: "foo",
- key: adminPk,
- access: git.AdminAccess,
- cfg: Config{
- AnonAccess: "read-only",
- Repos: []RepoConfig{
- {
- Repo: "foo",
- },
- },
- Users: []User{
- {
- Admin: true,
- PublicKeys: []string{
- adminKey,
- },
- },
- },
- },
- },
- {
- name: "anon access: read-write, anonymous user",
- repo: "foo",
- access: git.ReadWriteAccess,
- cfg: Config{
- AnonAccess: "read-write",
- Repos: []RepoConfig{
- {
- Repo: "foo",
- },
- },
- },
- },
- {
- name: "anon access: read-write, authd user",
- repo: "foo",
- key: dummyPk,
- access: git.ReadWriteAccess,
- cfg: Config{
- AnonAccess: "read-write",
- Repos: []RepoConfig{
- {
- Repo: "foo",
- },
- },
- Users: []User{
- {
- PublicKeys: []string{
- dummyKey,
- },
- },
- },
- },
- }, {
- name: "anon access: read-write, admin user",
- repo: "foo",
- key: adminPk,
- access: git.AdminAccess,
- cfg: Config{
- AnonAccess: "read-write",
- Repos: []RepoConfig{
- {
- Repo: "foo",
- },
- },
- Users: []User{
- {
- Admin: true,
- PublicKeys: []string{
- adminKey,
- },
- },
- },
- },
- },
- {
- name: "anon access: admin-access, anonymous user",
- repo: "foo",
- access: git.AdminAccess,
- cfg: Config{
- AnonAccess: "admin-access",
- Repos: []RepoConfig{
- {
- Repo: "foo",
- },
- },
- },
- },
- {
- name: "anon access: admin-access, authd user",
- repo: "foo",
- key: dummyPk,
- access: git.AdminAccess,
- cfg: Config{
- AnonAccess: "admin-access",
- Repos: []RepoConfig{
- {
- Repo: "foo",
- },
- },
- Users: []User{
- {
- PublicKeys: []string{
- dummyKey,
- },
- },
- },
- },
- }, {
- name: "anon access: admin-access, admin user",
- repo: "foo",
- key: adminPk,
- access: git.AdminAccess,
- cfg: Config{
- AnonAccess: "admin-access",
- Repos: []RepoConfig{
- {
- Repo: "foo",
- },
- },
- Users: []User{
- {
- Admin: true,
- PublicKeys: []string{
- adminKey,
- },
- },
- },
- },
- },
-
- // Collabs
- {
- name: "anon access: no-access, authd user, collab",
- key: dummyPk,
- repo: "foo",
- access: git.ReadWriteAccess,
- cfg: Config{
- AnonAccess: "no-access",
- Repos: []RepoConfig{
- {
- Repo: "foo",
- Collabs: []string{
- "user",
- },
- },
- },
- Users: []User{
- {
- Name: "user",
- PublicKeys: []string{
- dummyKey,
- },
- },
- },
- },
- },
- {
- name: "anon access: no-access, authd user, collab, private repo",
- key: dummyPk,
- repo: "foo",
- access: git.ReadWriteAccess,
- cfg: Config{
- AnonAccess: "no-access",
- Repos: []RepoConfig{
- {
- Repo: "foo",
- Private: true,
- Collabs: []string{
- "user",
- },
- },
- },
- Users: []User{
- {
- Name: "user",
- PublicKeys: []string{
- dummyKey,
- },
- },
- },
- },
- },
- {
- name: "anon access: no-access, admin user, collab, private repo",
- repo: "foo",
- key: adminPk,
- access: git.AdminAccess,
- cfg: Config{
- AnonAccess: "no-access",
- Repos: []RepoConfig{
- {
- Repo: "foo",
- Private: true,
- Collabs: []string{
- "user",
- },
- },
- },
- Users: []User{
- {
- Name: "admin",
- Admin: true,
- PublicKeys: []string{
- adminKey,
- },
- },
- },
- },
- },
- {
- name: "anon access: read-only, authd user, collab, private repo",
- repo: "foo",
- key: dummyPk,
- access: git.ReadWriteAccess,
- cfg: Config{
- AnonAccess: "read-only",
- Repos: []RepoConfig{
- {
- Repo: "foo",
- Private: true,
- Collabs: []string{
- "user",
- },
- },
- },
- Users: []User{
- {
- Name: "user",
- PublicKeys: []string{
- dummyKey,
- },
- },
- },
- },
- },
- {
- name: "anon access: admin-access, anonymous user, collab",
- repo: "foo",
- access: git.AdminAccess,
- cfg: Config{
- AnonAccess: "admin-access",
- Repos: []RepoConfig{
- {
- Repo: "foo",
- Collabs: []string{
- "user",
- },
- },
- },
- },
- },
- {
- name: "anon access: admin-access, authd user, collab",
- repo: "foo",
- key: dummyPk,
- access: git.AdminAccess,
- cfg: Config{
- AnonAccess: "admin-access",
- Repos: []RepoConfig{
- {
- Repo: "foo",
- Collabs: []string{
- "user",
- },
- },
- },
- Users: []User{
- {
- Name: "user",
- PublicKeys: []string{
- dummyKey,
- },
- },
- },
- },
- }, {
- name: "anon access: admin-access, admin user, collab",
- repo: "foo",
- key: adminPk,
- access: git.AdminAccess,
- cfg: Config{
- AnonAccess: "admin-access",
- Repos: []RepoConfig{
- {
- Repo: "foo",
- Collabs: []string{
- "user",
- },
- },
- },
- Users: []User{
- {
- Name: "admin",
- Admin: true,
- PublicKeys: []string{
- adminKey,
- },
- },
- },
- },
- },
-
- // New repo
- {
- name: "anon access: no-access, anonymous user, new repo",
- access: git.NoAccess,
- repo: "foo",
- cfg: Config{
- AnonAccess: "no-access",
- },
- },
- {
- name: "anon access: no-access, authd user, new repo",
- key: dummyPk,
- repo: "foo",
- access: git.ReadOnlyAccess,
- cfg: Config{
- AnonAccess: "no-access",
- Users: []User{
- {
- PublicKeys: []string{
- dummyKey,
- },
- },
- },
- },
- },
- {
- name: "anon access: no-access, authd user, new repo, with user",
- key: dummyPk,
- repo: "foo",
- access: git.NoAccess,
- cfg: Config{
- AnonAccess: "no-access",
- Users: []User{
- {
- PublicKeys: []string{
- adminKey,
- },
- },
- },
- },
- },
- {
- name: "anon access: no-access, admin user, new repo",
- repo: "foo",
- key: adminPk,
- access: git.AdminAccess,
- cfg: Config{
- AnonAccess: "no-access",
- Users: []User{
- {
- Admin: true,
- PublicKeys: []string{
- adminKey,
- },
- },
- },
- },
- },
- {
- name: "anon access: read-only, anonymous user, new repo",
- repo: "foo",
- access: git.ReadOnlyAccess,
- cfg: Config{
- AnonAccess: "read-only",
- },
- },
- {
- name: "anon access: read-only, authd user, new repo",
- repo: "foo",
- key: dummyPk,
- access: git.ReadOnlyAccess,
- cfg: Config{
- AnonAccess: "read-only",
- Users: []User{
- {
- PublicKeys: []string{
- dummyKey,
- },
- },
- },
- },
- },
- {
- name: "anon access: read-only, admin user, new repo",
- repo: "foo",
- key: adminPk,
- access: git.AdminAccess,
- cfg: Config{
- AnonAccess: "read-only",
- Users: []User{
- {
- Admin: true,
- PublicKeys: []string{
- adminKey,
- },
- },
- },
- },
- },
- {
- name: "anon access: read-write, anonymous user, new repo",
- repo: "foo",
- access: git.ReadWriteAccess,
- cfg: Config{
- AnonAccess: "read-write",
- },
- },
- {
- name: "anon access: read-write, authd user, new repo",
- repo: "foo",
- key: dummyPk,
- access: git.ReadWriteAccess,
- cfg: Config{
- AnonAccess: "read-write",
- Users: []User{
- {
- PublicKeys: []string{
- dummyKey,
- },
- },
- },
- },
- },
- {
- name: "anon access: read-write, admin user, new repo",
- repo: "foo",
- key: adminPk,
- access: git.AdminAccess,
- cfg: Config{
- AnonAccess: "read-write",
- Users: []User{
- {
- Admin: true,
- PublicKeys: []string{
- adminKey,
- },
- },
- },
- },
- },
- {
- name: "anon access: admin-access, anonymous user, new repo",
- repo: "foo",
- access: git.AdminAccess,
- cfg: Config{
- AnonAccess: "admin-access",
- },
- },
- {
- name: "anon access: admin-access, authd user, new repo",
- repo: "foo",
- key: dummyPk,
- access: git.AdminAccess,
- cfg: Config{
- AnonAccess: "admin-access",
- Users: []User{
- {
- PublicKeys: []string{
- dummyKey,
- },
- },
- },
- },
- },
- {
- name: "anon access: admin-access, admin user, new repo",
- repo: "foo",
- key: adminPk,
- access: git.AdminAccess,
- cfg: Config{
- AnonAccess: "admin-access",
- Users: []User{
- {
- Admin: true,
- PublicKeys: []string{
- adminKey,
- },
- },
- },
- },
- },
-
- // No users
- {
- name: "anon access: read-only, no users",
- repo: "foo",
- access: git.ReadOnlyAccess,
- cfg: Config{
- AnonAccess: "read-only",
- },
- },
- {
- name: "anon access: read-write, no users",
- repo: "foo",
- access: git.ReadWriteAccess,
- cfg: Config{
- AnonAccess: "read-write",
- },
- },
- }
- for _, c := range cases {
- t.Run(c.name, func(t *testing.T) {
- is := is.New(t)
- al := c.cfg.accessForKey(c.repo, c.key)
- is.Equal(al, c.access)
- })
- }
-}
@@ -1,334 +0,0 @@
-package config
-
-import (
- "bytes"
- "encoding/json"
- "errors"
- "io/fs"
- "path/filepath"
- "strings"
- "sync"
- "text/template"
- "time"
-
- "github.com/charmbracelet/log"
-
- "golang.org/x/crypto/ssh"
- "gopkg.in/yaml.v3"
-
- "fmt"
- "os"
-
- "github.com/charmbracelet/soft-serve/git"
- "github.com/charmbracelet/soft-serve/server/config"
- "github.com/go-git/go-billy/v5/memfs"
- ggit "github.com/go-git/go-git/v5"
- "github.com/go-git/go-git/v5/plumbing/object"
- "github.com/go-git/go-git/v5/plumbing/transport"
- "github.com/go-git/go-git/v5/storage/memory"
-)
-
-var (
- // ErrNoConfig is returned when a repo has no config file.
- ErrNoConfig = errors.New("no config file found")
-)
-
-// Config is the Soft Serve configuration.
-type Config struct {
- Name string `yaml:"name" json:"name"`
- Host string `yaml:"host" json:"host"`
- Port int `yaml:"port" json:"port"`
- AnonAccess string `yaml:"anon-access" json:"anon-access"`
- AllowKeyless bool `yaml:"allow-keyless" json:"allow-keyless"`
- Users []User `yaml:"users" json:"users"`
- Repos []RepoConfig `yaml:"repos" json:"repos"`
- Source *RepoSource `yaml:"-" json:"-"`
- Cfg *config.Config `yaml:"-" json:"-"`
- mtx sync.Mutex
-}
-
-// User contains user-level configuration for a repository.
-type User struct {
- Name string `yaml:"name" json:"name"`
- Admin bool `yaml:"admin" json:"admin"`
- PublicKeys []string `yaml:"public-keys" json:"public-keys"`
- CollabRepos []string `yaml:"collab-repos" json:"collab-repos"`
-}
-
-// RepoConfig is a repository configuration.
-type RepoConfig struct {
- Name string `yaml:"name" json:"name"`
- Repo string `yaml:"repo" json:"repo"`
- Note string `yaml:"note" json:"note"`
- Private bool `yaml:"private" json:"private"`
- Readme string `yaml:"readme" json:"readme"`
- Collabs []string `yaml:"collabs" json:"collabs"`
-}
-
-// NewConfig creates a new internal Config struct.
-func NewConfig(cfg *config.Config) (*Config, error) {
- var anonAccess string
- var yamlUsers string
- var displayHost string
- host := cfg.Host
- port := cfg.Port
-
- pks := make([]string, 0)
- for _, k := range cfg.InitialAdminKeys {
- if bts, err := os.ReadFile(k); err == nil {
- // pk is a file, set its contents as pk
- k = string(bts)
- }
- var pk = strings.TrimSpace(k)
- if pk == "" {
- continue
- }
- // it is a valid ssh key, nothing to do
- if _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk)); err != nil {
- return nil, fmt.Errorf("invalid initial admin key %q: %w", k, err)
- }
- pks = append(pks, pk)
- }
-
- rs := NewRepoSource(cfg.RepoPath)
- c := &Config{
- Cfg: cfg,
- }
- c.Host = cfg.Host
- c.Port = port
- c.Source = rs
- // Grant read-write access when no keys are provided.
- if len(pks) == 0 {
- anonAccess = "read-write"
- } else {
- anonAccess = "read-only"
- }
- if host == "" {
- displayHost = "localhost"
- } else {
- displayHost = host
- }
- yamlConfig := fmt.Sprintf(defaultConfig,
- displayHost,
- port,
- anonAccess,
- len(pks) == 0,
- )
- if len(pks) == 0 {
- yamlUsers = defaultUserConfig
- } else {
- var result string
- for _, pk := range pks {
- result += fmt.Sprintf(" - %s\n", pk)
- }
- yamlUsers = fmt.Sprintf(hasKeyUserConfig, result)
- }
- yaml := fmt.Sprintf("%s%s%s", yamlConfig, yamlUsers, exampleUserConfig)
- err := c.createDefaultConfigRepo(yaml)
- if err != nil {
- return nil, err
- }
- return c, nil
-}
-
-// readConfig reads the config file for the repo. All config files are stored in
-// the config repo.
-func (cfg *Config) readConfig(repo string, v interface{}) error {
- cr, err := cfg.Source.GetRepo("config")
- if err != nil {
- return err
- }
- // Parse YAML files
- var cy string
- for _, ext := range []string{".yaml", ".yml"} {
- cy, _, err = cr.LatestFile(repo + ext)
- if err != nil && !errors.Is(err, git.ErrFileNotFound) {
- return err
- } else if err == nil {
- break
- }
- }
- // Parse JSON files
- cj, _, err := cr.LatestFile(repo + ".json")
- if err != nil && !errors.Is(err, git.ErrFileNotFound) {
- return err
- }
- if cy != "" {
- err = yaml.Unmarshal([]byte(cy), v)
- if err != nil {
- return err
- }
- } else if cj != "" {
- err = json.Unmarshal([]byte(cj), v)
- if err != nil {
- return err
- }
- } else {
- return ErrNoConfig
- }
- return nil
-}
-
-// Reload reloads the configuration.
-func (cfg *Config) Reload() error {
- cfg.mtx.Lock()
- defer cfg.mtx.Unlock()
- err := cfg.Source.LoadRepos()
- if err != nil {
- return err
- }
- if err := cfg.readConfig("config", cfg); err != nil {
- return fmt.Errorf("error reading config: %w", err)
- }
- // sanitize repo configs
- repos := make(map[string]RepoConfig, 0)
- for _, r := range cfg.Repos {
- repos[r.Repo] = r
- }
- for _, r := range cfg.Source.AllRepos() {
- var rc RepoConfig
- repo := r.Repo()
- if repo == "config" {
- continue
- }
- if err := cfg.readConfig(repo, &rc); err != nil {
- if !errors.Is(err, ErrNoConfig) {
- log.Error("error reading config", "err", err)
- }
- continue
- }
- repos[r.Repo()] = rc
- }
- cfg.Repos = make([]RepoConfig, 0, len(repos))
- for n, r := range repos {
- r.Repo = n
- cfg.Repos = append(cfg.Repos, r)
- }
- // Populate readmes and descriptions
- for _, r := range cfg.Source.AllRepos() {
- repo := r.Repo()
- err = r.UpdateServerInfo()
- if err != nil {
- log.Error("error updating server info", "repo", repo, "err", err)
- }
- pat := "README*"
- rp := ""
- for _, rr := range cfg.Repos {
- if repo == rr.Repo {
- rp = rr.Readme
- r.name = rr.Name
- r.description = rr.Note
- r.private = rr.Private
- break
- }
- }
- if rp != "" {
- pat = rp
- }
- rm := ""
- fc, fp, _ := r.LatestFile(pat)
- rm = fc
- if repo == "config" {
- md, err := templatize(rm, cfg)
- if err != nil {
- return err
- }
- rm = md
- }
- r.SetReadme(rm, fp)
- }
- return nil
-}
-
-func createFile(path string, content string) error {
- f, err := os.Create(path)
- if err != nil {
- return err
- }
- defer f.Close()
- _, err = f.WriteString(content)
- if err != nil {
- return err
- }
- return f.Sync()
-}
-
-func (cfg *Config) createDefaultConfigRepo(yaml string) error {
- cn := "config"
- rp := filepath.Join(cfg.Cfg.RepoPath, cn)
- rs := cfg.Source
- err := rs.LoadRepo(cn)
- if errors.Is(err, fs.ErrNotExist) {
- repo, err := ggit.PlainInit(rp, true)
- if err != nil {
- return err
- }
- repo, err = ggit.Clone(memory.NewStorage(), memfs.New(), &ggit.CloneOptions{
- URL: rp,
- })
- if err != nil && err != transport.ErrEmptyRemoteRepository {
- return err
- }
- wt, err := repo.Worktree()
- if err != nil {
- return err
- }
- rm, err := wt.Filesystem.Create("README.md")
- if err != nil {
- return err
- }
- _, err = rm.Write([]byte(defaultReadme))
- if err != nil {
- return err
- }
- _, err = wt.Add("README.md")
- if err != nil {
- return err
- }
- cf, err := wt.Filesystem.Create("config.yaml")
- if err != nil {
- return err
- }
- _, err = cf.Write([]byte(yaml))
- if err != nil {
- return err
- }
- _, err = wt.Add("config.yaml")
- if err != nil {
- return err
- }
- author := object.Signature{
- Name: "Soft Serve Server",
- Email: "vt100@charm.sh",
- When: time.Now(),
- }
- _, err = wt.Commit("Default init", &ggit.CommitOptions{
- All: true,
- Author: &author,
- Committer: &author,
- })
- if err != nil {
- return err
- }
- err = repo.Push(&ggit.PushOptions{})
- if err != nil {
- return err
- }
- } else if err != nil {
- return err
- }
- return cfg.Reload()
-}
-
-func templatize(mdt string, tmpl interface{}) (string, error) {
- t, err := template.New("readme").Parse(mdt)
- if err != nil {
- return "", err
- }
- buf := &bytes.Buffer{}
- err = t.Execute(buf, tmpl)
- if err != nil {
- return "", err
- }
- return buf.String(), nil
-}
@@ -1,37 +0,0 @@
-package config
-
-import (
- "testing"
-
- "github.com/charmbracelet/soft-serve/server/config"
- "github.com/matryer/is"
-)
-
-func TestMultipleInitialKeys(t *testing.T) {
- cfg, err := NewConfig(&config.Config{
- RepoPath: t.TempDir(),
- KeyPath: t.TempDir(),
- InitialAdminKeys: []string{
- "testdata/k1.pub",
- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b",
- },
- })
- is := is.New(t)
- is.NoErr(err)
- err = cfg.Reload()
- is.NoErr(err)
- is.Equal(cfg.Users[0].PublicKeys, []string{
- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH a@b",
- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b",
- }) // should have both keys
-}
-
-func TestEmptyInitialKeys(t *testing.T) {
- cfg, err := NewConfig(&config.Config{
- RepoPath: t.TempDir(),
- KeyPath: t.TempDir(),
- })
- is := is.New(t)
- is.NoErr(err)
- is.Equal(len(cfg.Users), 0) // should not have any users
-}
@@ -1,58 +0,0 @@
-package config
-
-const defaultReadme = "# Soft Serve\n\n Welcome! You can configure your Soft Serve server by cloning this repo and pushing changes.\n\n```\ngit clone ssh://{{.Host}}:{{.Port}}/config\n```"
-
-const defaultConfig = `# The name of the server to show in the TUI.
-name: Soft Serve
-
-# The host and port to display in the TUI. You may want to change this if your
-# server is accessible from a different host and/or port that what it's
-# actually listening on (for example, if it's behind a reverse proxy).
-host: %s
-port: %d
-
-# Access level for anonymous users. Options are: admin-access, read-write,
-# read-only, and no-access.
-anon-access: %s
-
-# You can grant read-only access to users without private keys. Any password
-# will be accepted.
-allow-keyless: %t
-
-# Customize repo display in the menu.
-repos:
- - name: Home
- repo: config
- private: true
- note: "Configuration and content repo for this server"
- readme: README.md
-`
-
-const hasKeyUserConfig = `
-
-# Authorized users. Admins have full access to all repos. Private repos are only
-# accessible by admins and collab users. Regular users can read public repos
-# based on your anon-access setting.
-users:
- - name: Admin
- admin: true
- public-keys:
-%s
-`
-
-const defaultUserConfig = `
-# users:
-# - name: Admin
-# admin: true
-# public-keys:
-# - ssh-ed25519 AAAA... # redacted
-# - ssh-rsa AAAAB3Nz... # redacted`
-
-const exampleUserConfig = `
-# - name: Example User
-# collab-repos:
-# - REPO
-# public-keys:
-# - ssh-ed25519 AAAA... # redacted
-# - ssh-rsa AAAAB3Nz... # redacted
-`
@@ -1,304 +0,0 @@
-package config
-
-import (
- "errors"
- "os"
- "path/filepath"
- "sync"
-
- "github.com/charmbracelet/log"
-
- "github.com/charmbracelet/soft-serve/git"
- "github.com/gobwas/glob"
- "github.com/golang/groupcache/lru"
-)
-
-// ErrMissingRepo indicates that the requested repository could not be found.
-var ErrMissingRepo = errors.New("missing repo")
-
-// Repo represents a Git repository.
-type Repo struct {
- name string
- description string
- path string
- repository *git.Repository
- readme string
- readmePath string
- head *git.Reference
- headCommit string
- refs []*git.Reference
- patchCache *lru.Cache
- private bool
-}
-
-// open opens a Git repository.
-func (rs *RepoSource) open(path string) (*Repo, error) {
- rg, err := git.Open(path)
- if err != nil {
- return nil, err
- }
- r := &Repo{
- path: path,
- repository: rg,
- patchCache: lru.New(1000),
- }
- _, err = r.HEAD()
- if err != nil {
- return nil, err
- }
- _, err = r.References()
- if err != nil {
- return nil, err
- }
- return r, nil
-}
-
-// IsPrivate returns true if the repository is private.
-func (r *Repo) IsPrivate() bool {
- return r.private
-}
-
-// Path returns the path to the repository.
-func (r *Repo) Path() string {
- return r.path
-}
-
-// Repo returns the repository directory name.
-func (r *Repo) Repo() string {
- return filepath.Base(r.path)
-}
-
-// Name returns the name of the repository.
-func (r *Repo) Name() string {
- if r.name == "" {
- return r.Repo()
- }
- return r.name
-}
-
-// Description returns the description for a repository.
-func (r *Repo) Description() string {
- return r.description
-}
-
-// Readme returns the readme and its path for the repository.
-func (r *Repo) Readme() (readme string, path string) {
- return r.readme, r.readmePath
-}
-
-// SetReadme sets the readme for the repository.
-func (r *Repo) SetReadme(readme, path string) {
- r.readme = readme
- r.readmePath = path
-}
-
-// HEAD returns the reference for a repository.
-func (r *Repo) HEAD() (*git.Reference, error) {
- if r.head != nil {
- return r.head, nil
- }
- h, err := r.repository.HEAD()
- if err != nil {
- return nil, err
- }
- r.head = h
- return h, nil
-}
-
-// GetReferences returns the references for a repository.
-func (r *Repo) References() ([]*git.Reference, error) {
- if r.refs != nil {
- return r.refs, nil
- }
- refs, err := r.repository.References()
- if err != nil {
- return nil, err
- }
- r.refs = refs
- return refs, nil
-}
-
-// Tree returns the git tree for a given path.
-func (r *Repo) Tree(ref *git.Reference, path string) (*git.Tree, error) {
- return r.repository.TreePath(ref, path)
-}
-
-// Diff returns the diff for a given commit.
-func (r *Repo) Diff(commit *git.Commit) (*git.Diff, error) {
- hash := commit.Hash.String()
- c, ok := r.patchCache.Get(hash)
- if ok {
- return c.(*git.Diff), nil
- }
- diff, err := r.repository.Diff(commit)
- if err != nil {
- return nil, err
- }
- r.patchCache.Add(hash, diff)
- return diff, nil
-}
-
-// CountCommits returns the number of commits for a repository.
-func (r *Repo) CountCommits(ref *git.Reference) (int64, error) {
- tc, err := r.repository.CountCommits(ref)
- if err != nil {
- return 0, err
- }
- return tc, nil
-}
-
-// Commit returns the commit for a given hash.
-func (r *Repo) Commit(hash string) (*git.Commit, error) {
- if hash == "HEAD" && r.headCommit != "" {
- hash = r.headCommit
- }
- c, err := r.repository.CatFileCommit(hash)
- if err != nil {
- return nil, err
- }
- r.headCommit = c.ID.String()
- return &git.Commit{
- Commit: c,
- Hash: git.Hash(c.ID.String()),
- }, nil
-}
-
-// CommitsByPage returns the commits for a repository.
-func (r *Repo) CommitsByPage(ref *git.Reference, page, size int) (git.Commits, error) {
- return r.repository.CommitsByPage(ref, page, size)
-}
-
-// Push pushes the repository to the remote.
-func (r *Repo) Push(remote, branch string) error {
- return r.repository.Push(remote, branch)
-}
-
-// RepoSource is a reference to an on-disk repositories.
-type RepoSource struct {
- Path string
- mtx sync.Mutex
- repos map[string]*Repo
-}
-
-// NewRepoSource creates a new RepoSource.
-func NewRepoSource(repoPath string) *RepoSource {
- err := os.MkdirAll(repoPath, os.ModeDir|os.FileMode(0700))
- if err != nil {
- log.Fatal(err)
- }
- rs := &RepoSource{Path: repoPath}
- rs.repos = make(map[string]*Repo, 0)
- return rs
-}
-
-// AllRepos returns all repositories for the given RepoSource.
-func (rs *RepoSource) AllRepos() []*Repo {
- rs.mtx.Lock()
- defer rs.mtx.Unlock()
- repos := make([]*Repo, 0, len(rs.repos))
- for _, r := range rs.repos {
- repos = append(repos, r)
- }
- return repos
-}
-
-// GetRepo returns a repository by name.
-func (rs *RepoSource) GetRepo(name string) (*Repo, error) {
- rs.mtx.Lock()
- defer rs.mtx.Unlock()
- r, ok := rs.repos[name]
- if !ok {
- return nil, ErrMissingRepo
- }
- return r, nil
-}
-
-// InitRepo initializes a new Git repository.
-func (rs *RepoSource) InitRepo(name string, bare bool) (*Repo, error) {
- rs.mtx.Lock()
- defer rs.mtx.Unlock()
- rp := filepath.Join(rs.Path, name)
- rg, err := git.Init(rp, bare)
- if err != nil {
- return nil, err
- }
- r := &Repo{
- path: rp,
- repository: rg,
- refs: []*git.Reference{
- git.NewReference(rp, git.RefsHeads+"master"),
- },
- }
- rs.repos[name] = r
- return r, nil
-}
-
-// LoadRepo loads a repository from disk.
-func (rs *RepoSource) LoadRepo(name string) error {
- rs.mtx.Lock()
- defer rs.mtx.Unlock()
- rp := filepath.Join(rs.Path, name)
- r, err := rs.open(rp)
- if err != nil {
- return err
- }
- rs.repos[name] = r
- return nil
-}
-
-// LoadRepos opens Git repositories.
-func (rs *RepoSource) LoadRepos() error {
- rd, err := os.ReadDir(rs.Path)
- if err != nil {
- return err
- }
- for _, de := range rd {
- if !de.IsDir() {
- log.Warn("not a directory", "path", filepath.Join(rs.Path, de.Name()))
- continue
- }
- err = rs.LoadRepo(de.Name())
- if err == git.ErrNotAGitRepository {
- continue
- }
- if err != nil {
- log.Warn("error loading repository", "path", filepath.Join(rs.Path, de.Name()), "err", err)
- continue
- }
- }
- return nil
-}
-
-// LatestFile returns the contents of the latest file at the specified path in
-// the repository and its file path.
-func (r *Repo) LatestFile(pattern string) (string, string, error) {
- g := glob.MustCompile(pattern)
- dir := filepath.Dir(pattern)
- t, err := r.repository.TreePath(r.head, dir)
- if err != nil {
- return "", "", err
- }
- ents, err := t.Entries()
- if err != nil {
- return "", "", err
- }
- for _, e := range ents {
- fp := filepath.Join(dir, e.Name())
- if e.IsTree() {
- continue
- }
- if g.Match(fp) {
- bts, err := e.Contents()
- if err != nil {
- return "", "", err
- }
- return string(bts), fp, nil
- }
- }
- return "", "", git.ErrFileNotFound
-}
-
-// UpdateServerInfo updates the server info for the repository.
-func (r *Repo) UpdateServerInfo() error {
- return r.repository.UpdateServerInfo()
-}
@@ -1 +0,0 @@
-ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH a@b
@@ -45,22 +45,25 @@ func main() {
log.Fatal("Setuid error", "err", err)
}
cfg := config.DefaultConfig()
- cfg.Port = *port
- s := server.NewServer(cfg)
+ cfg.SSH.ListenAddr = fmt.Sprintf(":%d", *port)
+ s, err := server.NewServer(cfg)
+ if err != nil {
+ log.Fatal(err)
+ }
done := make(chan os.Signal, 1)
signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
- log.Print("Starting SSH server", "addr", fmt.Sprintf("%s:%d", cfg.BindAddr, cfg.Port))
+ log.Print("Starting SSH server", "addr", cfg.SSH.ListenAddr)
go func() {
- if err := s.Serve(ls); err != nil {
+ if err := s.SSHServer.Serve(ls); err != nil {
log.Fatal(err)
}
}()
<-done
- log.Print("Stopping SSH server", fmt.Sprintf("%s:%d", cfg.BindAddr, cfg.Port))
+ log.Print("Stopping SSH server", "addr", cfg.SSH.ListenAddr)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer func() { cancel() }()
if err := s.Shutdown(ctx); err != nil {
@@ -0,0 +1,11 @@
+package git
+
+import "github.com/gogs/git-module"
+
+// RunInDirOptions are options for RunInDir.
+type RunInDirOptions = git.RunInDirOptions
+
+// NewCommand creates a new git command.
+func NewCommand(args ...string) *git.Command {
+ return git.NewCommand(args...)
+}
@@ -0,0 +1,51 @@
+package git
+
+// ConfigOptions are options for Config.
+type ConfigOptions struct {
+ File string
+ All bool
+ Add bool
+ CommandOptions
+}
+
+// Config gets a git configuration.
+func Config(key string, opts ...ConfigOptions) (string, error) {
+ var opt ConfigOptions
+ if len(opts) > 0 {
+ opt = opts[0]
+ }
+ cmd := NewCommand("config")
+ if opt.File != "" {
+ cmd.AddArgs("--file", opt.File)
+ }
+ if opt.All {
+ cmd.AddArgs("--get-all")
+ }
+ for _, a := range opt.Args {
+ cmd.AddArgs(a)
+ }
+ cmd.AddArgs(key)
+ bts, err := cmd.Run()
+ if err != nil {
+ return "", err
+ }
+ return string(bts), nil
+}
+
+// SetConfig sets a git configuration.
+func SetConfig(key string, value string, opts ...ConfigOptions) error {
+ var opt ConfigOptions
+ if len(opts) > 0 {
+ opt = opts[0]
+ }
+ cmd := NewCommand("config")
+ if opt.File != "" {
+ cmd.AddArgs("--file", opt.File)
+ }
+ for _, a := range opt.Args {
+ cmd.AddArgs(a)
+ }
+ cmd.AddArgs(key, value)
+ _, err := cmd.Run()
+ return err
+}
@@ -11,8 +11,8 @@ var (
ErrFileNotFound = errors.New("file not found")
// ErrDirectoryNotFound is returned when a directory is not found.
ErrDirectoryNotFound = errors.New("directory not found")
- // ErrReferenceNotFound is returned when a reference is not found.
- ErrReferenceNotFound = errors.New("reference not found")
+ // ErrReferenceNotExist is returned when a reference does not exist.
+ ErrReferenceNotExist = git.ErrReferenceNotExist
// ErrRevisionNotExist is returned when a revision is not found.
ErrRevisionNotExist = git.ErrRevisionNotExist
// ErrNotAGitRepository is returned when the given path is not a Git repository.
@@ -79,7 +79,7 @@ func (r *Repository) Name() string {
// HEAD returns the HEAD reference for a repository.
func (r *Repository) HEAD() (*Reference, error) {
- rn, err := r.SymbolicRef()
+ rn, err := r.Repository.SymbolicRef(git.SymbolicRefOptions{Name: "HEAD"})
if err != nil {
return nil, err
}
@@ -212,3 +212,41 @@ func (r *Repository) UpdateServerInfo() error {
_, err := cmd.RunInDir(r.Path)
return err
}
+
+// Config returns the config value for the given key.
+func (r *Repository) Config(key string, opts ...ConfigOptions) (string, error) {
+ dir, err := gitDir(r.Repository)
+ if err != nil {
+ return "", err
+ }
+ var opt ConfigOptions
+ if len(opts) > 0 {
+ opt = opts[0]
+ }
+ opt.File = filepath.Join(dir, "config")
+ return Config(key, opt)
+}
+
+// SetConfig sets the config value for the given key.
+func (r *Repository) SetConfig(key, value string, opts ...ConfigOptions) error {
+ dir, err := gitDir(r.Repository)
+ if err != nil {
+ return err
+ }
+ var opt ConfigOptions
+ if len(opts) > 0 {
+ opt = opts[0]
+ }
+ opt.File = filepath.Join(dir, "config")
+ return SetConfig(key, value, opt)
+}
+
+// SymbolicRef returns or updates the symbolic reference for the given name.
+// Both name and ref can be empty.
+func (r *Repository) SymbolicRef(name string, ref string) (string, error) {
+ opt := git.SymbolicRefOptions{
+ Name: name,
+ Ref: ref,
+ }
+ return r.Repository.SymbolicRef(opt)
+}
@@ -0,0 +1,9 @@
+package git
+
+import "github.com/gogs/git-module"
+
+// CommandOptions contain options for running a git command.
+type CommandOptions = git.CommandOptions
+
+// CloneOptions contain options for cloning a repository.
+type CloneOptions = git.CloneOptions
@@ -9,10 +9,8 @@ require (
github.com/charmbracelet/bubbletea v0.23.2
github.com/charmbracelet/glamour v0.6.0
github.com/charmbracelet/lipgloss v0.7.1
- github.com/charmbracelet/wish v0.7.0
+ github.com/charmbracelet/wish v1.1.0
github.com/dustin/go-humanize v1.0.1
- github.com/gliderlabs/ssh v0.3.5
- github.com/go-git/go-billy/v5 v5.4.1
github.com/go-git/go-git/v5 v5.6.1
github.com/matryer/is v1.4.1
github.com/muesli/reflow v0.3.0
@@ -22,39 +20,30 @@ require (
require (
github.com/aymanbagabas/go-osc52 v1.2.2
- github.com/charmbracelet/keygen v0.3.0
github.com/charmbracelet/log v0.2.1
+ github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103
github.com/gobwas/glob v0.2.3
github.com/gogs/git-module v1.8.1
- github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da
github.com/lrstanley/bubblezone v0.0.0-20220716194435-3cb8c52f6a8f
github.com/muesli/mango-cobra v1.2.0
github.com/muesli/roff v0.1.0
github.com/spf13/cobra v1.6.1
golang.org/x/crypto v0.7.0
- gopkg.in/yaml.v3 v3.0.1
+ golang.org/x/sync v0.1.0
)
require (
- github.com/Microsoft/go-winio v0.5.2 // indirect
- github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect
- github.com/acomagu/bufpipe v1.0.4 // indirect
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/caarlos0/sshmarshal v0.1.0 // indirect
- github.com/cloudflare/circl v1.1.0 // indirect
+ github.com/charmbracelet/keygen v0.3.0 // indirect
github.com/containerd/console v1.0.3 // indirect
github.com/dlclark/regexp2 v1.4.0 // indirect
- github.com/emirpasic/gods v1.18.1 // indirect
- github.com/go-git/gcfg v1.5.0 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/gorilla/css v1.0.0 // indirect
- github.com/imdario/mergo v0.3.13 // indirect
github.com/inconshreveable/mousetrap v1.0.1 // indirect
- github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
- github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.18 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
@@ -67,18 +56,13 @@ require (
github.com/muesli/mango v0.1.0 // indirect
github.com/muesli/mango-pflag v0.1.0 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
- github.com/pjbgf/sha1cd v0.3.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/sahilm/fuzzy v0.1.0 // indirect
- github.com/skeema/knownhosts v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
- github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/yuin/goldmark v1.5.2 // indirect
github.com/yuin/goldmark-emoji v1.0.1 // indirect
golang.org/x/net v0.8.0 // indirect
- golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.6.0 // indirect
golang.org/x/term v0.6.0 // indirect
golang.org/x/text v0.8.0 // indirect
- gopkg.in/warnings.v0 v0.1.2 // indirect
)
@@ -1,19 +1,10 @@
-github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
-github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0=
-github.com/Microsoft/go-winio v0.5.2 h1:a9IhgEQBCUEk6QCdml9CiJGhAws+YwffDHEMp1VMrpA=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
-github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo=
-github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA=
github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g=
-github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
-github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ=
github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
-github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
-github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
@@ -46,11 +37,13 @@ github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0
github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk=
github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E=
github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c=
+github.com/charmbracelet/log v0.1.2/go.mod h1:86XdIdmrubqtL/6u0z+jGFol1bQejBGG/qPSTwGZuQQ=
github.com/charmbracelet/log v0.2.1 h1:1z7jpkk4yKyjwlmKmKMM5qnEDSpV32E7XtWhuv0mTZE=
github.com/charmbracelet/log v0.2.1/go.mod h1:GwFfjewhcVDWLrpAbY5A0Hin9YOlEn40eWT4PNaxFT4=
-github.com/charmbracelet/wish v0.7.0 h1:rdfacCWaKCQpCMPbOKfi68GYqsb+9CnUzN1Ov/INZJ0=
-github.com/charmbracelet/wish v0.7.0/go.mod h1:16EQz7k3hEgPkPENghcpEddvlrmucIudE0jnczKr+k4=
-github.com/cloudflare/circl v1.1.0 h1:bZgT/A+cikZnKIwn7xL2OBj012Bmvho/o6RpRvv3GKY=
+github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103 h1:wpHMERIN0pQZE635jWwT1dISgfjbpUcEma+fbPKSMCU=
+github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103/go.mod h1:0Vm2/8yBljiLDnGJHU8ehswfawrEybGk33j5ssqKQVM=
+github.com/charmbracelet/wish v1.1.0 h1:0ArX9SOG70saqd23NYjoS56oLPVNgqcQegkz1Lw+4zY=
+github.com/charmbracelet/wish v1.1.0/go.mod h1:yHbm0hs/qX4lFE7nrhAcXjFYc8bxMIfSqJOfOYfwyYo=
github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I=
github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
@@ -63,23 +56,12 @@ github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
-github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
-github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
-github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
-github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
-github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY=
github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4=
-github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4=
github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E=
-github.com/go-git/go-billy/v5 v5.2.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
-github.com/go-git/go-billy/v5 v5.4.1 h1:Uwp5tDRkPr+l/TnbHOQzp+tmJfLceOlbVucgpTz8ix4=
github.com/go-git/go-billy/v5 v5.4.1/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg=
-github.com/go-git/go-git-fixtures/v4 v4.2.1/go.mod h1:K8zd3kDUAykwTdDCr+I0per6Y6vMiRR/nnVTBtavnB0=
-github.com/go-git/go-git-fixtures/v4 v4.3.1 h1:y5z6dd3qi8Hl+stezc8p3JxDkoTRqMAlKnXHuzrfjTQ=
github.com/go-git/go-git-fixtures/v4 v4.3.1/go.mod h1:8LHG1a3SRW71ettAD/jW13h8c6AqjVSeL11RAdgaqpo=
-github.com/go-git/go-git/v5 v5.4.2/go.mod h1:gQ1kArt6d+n+BGd+/B/I74HwRTLhth2+zti4ihgckDc=
github.com/go-git/go-git/v5 v5.6.1 h1:q4ZRqQl4pR/ZJHc1L5CFjGA1a10u76aV1iC+nh+bHsk=
github.com/go-git/go-git/v5 v5.6.1/go.mod h1:mvyoL6Unz0PiTQrGQfSfiLFhBH1c1e84ylC2MDs4ee8=
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
@@ -88,28 +70,17 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/gogs/git-module v1.8.1 h1:yC5BZ3unJOXC8N6/FgGQ8EtJXpOd217lgDcd2aPOxkc=
github.com/gogs/git-module v1.8.1/go.mod h1:Y3rsSqtFZEbn7lp+3gWf42GKIY1eNTtLt7JrmOy0yAQ=
-github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
-github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
-github.com/hashicorp/golang-lru/v2 v2.0.1/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
-github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
-github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk=
+github.com/hashicorp/golang-lru/v2 v2.0.2/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
-github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
-github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
-github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
-github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
@@ -124,7 +95,6 @@ github.com/lrstanley/bubblezone v0.0.0-20220716194435-3cb8c52f6a8f/go.mod h1:Cxa
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA=
-github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
@@ -173,10 +143,7 @@ github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4AN
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
-github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
-github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -188,9 +155,7 @@ github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
-github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
-github.com/skeema/knownhosts v1.1.0 h1:Wvr9V0MxhjRbl3f9nMnKnFfiWTJmtECJ9Njkea3ysW0=
github.com/skeema/knownhosts v1.1.0/go.mod h1:sKFq3RD6/TKZkSWn8boUbDC7Qkgcv+8XXijpFO6roag=
github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM=
github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA=
@@ -198,7 +163,6 @@ github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUq
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
@@ -209,8 +173,6 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0=
-github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
@@ -219,10 +181,7 @@ github.com/yuin/goldmark v1.5.2/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5ta
github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os=
github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ=
golang.org/x/arch v0.1.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
-golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
-golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220307211146-efcb8507fb70/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
@@ -237,7 +196,6 @@ golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
@@ -249,22 +207,16 @@ golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -297,24 +249,21 @@ golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
-golang.org/x/time v0.0.0-20220411224347-583f2d630306/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
-gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -32,7 +32,7 @@ import (
"github.com/charmbracelet/log"
"github.com/charmbracelet/soft-serve/git"
"github.com/charmbracelet/soft-serve/server/backend"
- "github.com/gliderlabs/ssh"
+ "github.com/charmbracelet/ssh"
gitm "github.com/gogs/git-module"
gossh "golang.org/x/crypto/ssh"
)
@@ -53,6 +53,14 @@ const (
var (
logger = log.WithPrefix("backend.file")
+
+ defaults = map[string]string{
+ serverName: "Soft Serve",
+ serverHost: "localhost",
+ serverPort: "23231",
+ anonAccess: backend.ReadOnlyAccess.String(),
+ allowKeyless: "true",
+ }
)
var _ backend.Backend = &FileBackend{}
@@ -114,8 +122,17 @@ func NewFileBackend(path string) (*FileBackend, error) {
}
}
for _, file := range []string{admins, anonAccess, allowKeyless, serverHost, serverName, serverPort} {
- if _, err := os.OpenFile(filepath.Join(path, file), os.O_RDONLY|os.O_CREATE, 0644); err != nil {
- return nil, err
+ fp := filepath.Join(path, file)
+ _, err := os.Stat(fp)
+ if errors.Is(err, fs.ErrNotExist) {
+ f, err := os.Create(fp)
+ if err != nil {
+ return nil, err
+ }
+ if c, ok := defaults[file]; ok {
+ io.WriteString(f, c) // nolint:errcheck
+ }
+ _ = f.Close()
}
}
return fb, nil
@@ -586,10 +603,7 @@ func (fb *FileBackend) SetDefaultBranch(repo string, branch string) error {
return err
}
- if _, err := r.SymbolicRef(gitm.SymbolicRefOptions{
- Name: "HEAD",
- Ref: gitm.RefsHeads + branch,
- }); err != nil {
+ if _, err := r.SymbolicRef("HEAD", gitm.RefsHeads+branch); err != nil {
logger.Debug("failed to set default branch", "err", err)
return err
}
@@ -0,0 +1,163 @@
+package noop
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/charmbracelet/soft-serve/git"
+ "github.com/charmbracelet/soft-serve/server/backend"
+ "golang.org/x/crypto/ssh"
+)
+
+var ErrNotImpl = fmt.Errorf("not implemented")
+
+var _ backend.Backend = (*Noop)(nil)
+
+var _ backend.AccessMethod = (*Noop)(nil)
+
+// Noop is a backend that does nothing. It's used for testing.
+type Noop struct {
+ Port string
+}
+
+// AccessLevel implements backend.AccessMethod
+func (*Noop) AccessLevel(repo string, pk ssh.PublicKey) backend.AccessLevel {
+ return backend.AdminAccess
+}
+
+// AddAdmin implements backend.Backend
+func (*Noop) AddAdmin(pk ssh.PublicKey) error {
+ return ErrNotImpl
+}
+
+// AddCollaborator implements backend.Backend
+func (*Noop) AddCollaborator(pk ssh.PublicKey, repo string) error {
+ return ErrNotImpl
+}
+
+// AllowKeyless implements backend.Backend
+func (*Noop) AllowKeyless() bool {
+ return true
+}
+
+// AnonAccess implements backend.Backend
+func (*Noop) AnonAccess() backend.AccessLevel {
+ return backend.AdminAccess
+}
+
+// CreateRepository implements backend.Backend
+func (*Noop) CreateRepository(name string, private bool) (backend.Repository, error) {
+ temp, err := os.MkdirTemp("", "soft-serve")
+ if err != nil {
+ return nil, err
+ }
+
+ rp := filepath.Join(temp, name)
+ _, err = git.Init(rp, private)
+ if err != nil {
+ return nil, err
+ }
+
+ return &repo{path: rp}, nil
+}
+
+// DefaultBranch implements backend.Backend
+func (*Noop) DefaultBranch(repo string) (string, error) {
+ return "", ErrNotImpl
+}
+
+// DeleteRepository implements backend.Backend
+func (*Noop) DeleteRepository(name string) error {
+ return ErrNotImpl
+}
+
+// Description implements backend.Backend
+func (*Noop) Description(repo string) string {
+ return ""
+}
+
+// IsAdmin implements backend.Backend
+func (*Noop) IsAdmin(pk ssh.PublicKey) bool {
+ return true
+}
+
+// IsCollaborator implements backend.Backend
+func (*Noop) IsCollaborator(pk ssh.PublicKey, repo string) bool {
+ return true
+}
+
+// IsPrivate implements backend.Backend
+func (*Noop) IsPrivate(repo string) bool {
+ return false
+}
+
+// RenameRepository implements backend.Backend
+func (*Noop) RenameRepository(oldName string, newName string) error {
+ return ErrNotImpl
+}
+
+// Repositories implements backend.Backend
+func (*Noop) Repositories() ([]backend.Repository, error) {
+ return nil, ErrNotImpl
+}
+
+// Repository implements backend.Backend
+func (*Noop) Repository(repo string) (backend.Repository, error) {
+ return nil, ErrNotImpl
+}
+
+// ServerHost implements backend.Backend
+func (*Noop) ServerHost() string {
+ return "localhost"
+}
+
+// ServerName implements backend.Backend
+func (*Noop) ServerName() string {
+ return "Soft Serve"
+}
+
+// ServerPort implements backend.Backend
+func (n *Noop) ServerPort() string {
+ return n.Port
+}
+
+// SetAllowKeyless implements backend.Backend
+func (*Noop) SetAllowKeyless(allow bool) error {
+ return ErrNotImpl
+}
+
+// SetAnonAccess implements backend.Backend
+func (*Noop) SetAnonAccess(level backend.AccessLevel) error {
+ return ErrNotImpl
+}
+
+// SetDefaultBranch implements backend.Backend
+func (*Noop) SetDefaultBranch(repo string, branch string) error {
+ return ErrNotImpl
+}
+
+// SetDescription implements backend.Backend
+func (*Noop) SetDescription(repo string, desc string) error {
+ return ErrNotImpl
+}
+
+// SetPrivate implements backend.Backend
+func (*Noop) SetPrivate(repo string, priv bool) error {
+ return ErrNotImpl
+}
+
+// SetServerHost implements backend.Backend
+func (*Noop) SetServerHost(host string) error {
+ return ErrNotImpl
+}
+
+// SetServerName implements backend.Backend
+func (*Noop) SetServerName(name string) error {
+ return ErrNotImpl
+}
+
+// SetServerPort implements backend.Backend
+func (*Noop) SetServerPort(port string) error {
+ return ErrNotImpl
+}
@@ -0,0 +1,32 @@
+package noop
+
+import (
+ "github.com/charmbracelet/soft-serve/git"
+ "github.com/charmbracelet/soft-serve/server/backend"
+)
+
+var _ backend.Repository = (*repo)(nil)
+
+type repo struct {
+ path string
+}
+
+// Description implements backend.Repository
+func (*repo) Description() string {
+ return ""
+}
+
+// IsPrivate implements backend.Repository
+func (*repo) IsPrivate() bool {
+ return false
+}
+
+// Name implements backend.Repository
+func (*repo) Name() string {
+ return ""
+}
+
+// Repository implements backend.Repository
+func (r *repo) Repository() (*git.Repository, error) {
+ return git.Open(r.path)
+}
@@ -1,123 +0,0 @@
-package cmd
-
-import (
- "fmt"
- "strings"
-
- "github.com/alecthomas/chroma/lexers"
- gansi "github.com/charmbracelet/glamour/ansi"
- "github.com/charmbracelet/lipgloss"
- "github.com/charmbracelet/soft-serve/config"
- "github.com/charmbracelet/soft-serve/ui/common"
- gitwish "github.com/charmbracelet/wish/git"
- "github.com/muesli/termenv"
- "github.com/spf13/cobra"
-)
-
-var (
- lineDigitStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("239"))
- lineBarStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("236"))
- dirnameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00AAFF"))
- filenameStyle = lipgloss.NewStyle()
- filemodeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777"))
-)
-
-// CatCommand returns a command that prints the contents of a file.
-func CatCommand() *cobra.Command {
- var linenumber bool
- var color bool
-
- catCmd := &cobra.Command{
- Use: "cat PATH",
- Short: "Outputs the contents of the file at path.",
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- ac, s := fromContext(cmd)
- ps := strings.Split(args[0], "/")
- rn := ps[0]
- fp := strings.Join(ps[1:], "/")
- auth := ac.AuthRepo(rn, s.PublicKey())
- if auth < gitwish.ReadOnlyAccess {
- return ErrUnauthorized
- }
- var repo *config.Repo
- repoExists := false
- for _, rp := range ac.Source.AllRepos() {
- if rp.Repo() == rn {
- repoExists = true
- repo = rp
- break
- }
- }
- if !repoExists {
- return ErrRepoNotFound
- }
- c, _, err := repo.LatestFile(fp)
- if err != nil {
- return err
- }
- if color {
- c, err = withFormatting(fp, c)
- if err != nil {
- return err
- }
- }
- if linenumber {
- c = withLineNumber(c, color)
- }
- fmt.Fprint(s, c)
- return nil
- },
- }
- catCmd.Flags().BoolVarP(&linenumber, "linenumber", "l", false, "Print line numbers")
- catCmd.Flags().BoolVarP(&color, "color", "c", false, "Colorize output")
-
- return catCmd
-}
-
-func withLineNumber(s string, color bool) string {
- lines := strings.Split(s, "\n")
- // NB: len() is not a particularly safe way to count string width (because
- // it's counting bytes instead of runes) but in this case it's okay
- // because we're only dealing with digits, which are one byte each.
- mll := len(fmt.Sprintf("%d", len(lines)))
- for i, l := range lines {
- digit := fmt.Sprintf("%*d", mll, i+1)
- bar := "β"
- if color {
- digit = lineDigitStyle.Render(digit)
- bar = lineBarStyle.Render(bar)
- }
- if i < len(lines)-1 || len(l) != 0 {
- // If the final line was a newline we'll get an empty string for
- // the final line, so drop the newline altogether.
- lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l)
- }
- }
- return strings.Join(lines, "\n")
-}
-
-func withFormatting(p, c string) (string, error) {
- zero := uint(0)
- lang := ""
- lexer := lexers.Match(p)
- if lexer != nil && lexer.Config() != nil {
- lang = lexer.Config().Name
- }
- formatter := &gansi.CodeBlockElement{
- Code: c,
- Language: lang,
- }
- r := strings.Builder{}
- styles := common.StyleConfig()
- styles.CodeBlock.Margin = &zero
- rctx := gansi.NewRenderContext(gansi.Options{
- Styles: styles,
- ColorProfile: termenv.TrueColor,
- })
- err := formatter.Render(&r, rctx)
- if err != nil {
- return "", err
- }
- return r.String(), nil
-}
@@ -3,8 +3,8 @@ package cmd
import (
"fmt"
- appCfg "github.com/charmbracelet/soft-serve/config"
- "github.com/gliderlabs/ssh"
+ "github.com/charmbracelet/soft-serve/server/config"
+ "github.com/charmbracelet/ssh"
"github.com/spf13/cobra"
)
@@ -68,18 +68,15 @@ func RootCommand() *cobra.Command {
rootCmd.SetUsageTemplate(usageTemplate)
rootCmd.CompletionOptions.DisableDefaultCmd = true
rootCmd.AddCommand(
- ReloadCommand(),
- CatCommand(),
- ListCommand(),
- GitCommand(),
+ RepoCommand(),
)
return rootCmd
}
-func fromContext(cmd *cobra.Command) (*appCfg.Config, ssh.Session) {
+func fromContext(cmd *cobra.Command) (*config.Config, ssh.Session) {
ctx := cmd.Context()
- ac := ctx.Value(ConfigCtxKey).(*appCfg.Config)
+ cfg := ctx.Value(ConfigCtxKey).(*config.Config)
s := ctx.Value(SessionCtxKey).(ssh.Session)
- return ac, s
+ return cfg, s
}
@@ -1,54 +0,0 @@
-package cmd
-
-import (
- "io"
- "os/exec"
-
- "github.com/charmbracelet/soft-serve/config"
- gitwish "github.com/charmbracelet/wish/git"
- "github.com/spf13/cobra"
-)
-
-// GitCommand returns a command that handles Git operations.
-func GitCommand() *cobra.Command {
- gitCmd := &cobra.Command{
- Use: "git REPO COMMAND",
- Short: "Perform Git operations on a repository.",
- RunE: func(cmd *cobra.Command, args []string) error {
- ac, s := fromContext(cmd)
- auth := ac.AuthRepo("config", s.PublicKey())
- if auth < gitwish.AdminAccess {
- return ErrUnauthorized
- }
- if len(args) < 1 {
- return runGit(nil, s, s, "")
- }
- var repo *config.Repo
- rn := args[0]
- repoExists := false
- for _, rp := range ac.Source.AllRepos() {
- if rp.Repo() == rn {
- repoExists = true
- repo = rp
- break
- }
- }
- if !repoExists {
- return ErrRepoNotFound
- }
- return runGit(nil, s, s, repo.Path(), args[1:]...)
- },
- }
- gitCmd.Flags().SetInterspersed(false)
-
- return gitCmd
-}
-
-func runGit(in io.Reader, out, err io.Writer, dir string, args ...string) error {
- cmd := exec.Command("git", args...)
- cmd.Stdin = in
- cmd.Stdout = out
- cmd.Stderr = err
- cmd.Dir = dir
- return cmd.Run()
-}
@@ -1,83 +0,0 @@
-package cmd
-
-import (
- "fmt"
- "path/filepath"
- "strings"
-
- "github.com/charmbracelet/soft-serve/git"
- gitwish "github.com/charmbracelet/wish/git"
- "github.com/spf13/cobra"
-)
-
-// ListCommand returns a command that list file or directory at path.
-func ListCommand() *cobra.Command {
- lsCmd := &cobra.Command{
- Use: "ls PATH",
- Aliases: []string{"list"},
- Short: "List file or directory at path.",
- Args: cobra.RangeArgs(0, 1),
- RunE: func(cmd *cobra.Command, args []string) error {
- ac, s := fromContext(cmd)
- rn := ""
- path := ""
- ps := []string{}
- if len(args) > 0 {
- path = filepath.Clean(args[0])
- ps = strings.Split(path, "/")
- rn = ps[0]
- auth := ac.AuthRepo(rn, s.PublicKey())
- if auth < gitwish.ReadOnlyAccess {
- return ErrUnauthorized
- }
- }
- if path == "" || path == "." || path == "/" {
- for _, r := range ac.Source.AllRepos() {
- if ac.AuthRepo(r.Repo(), s.PublicKey()) >= gitwish.ReadOnlyAccess {
- fmt.Fprintln(s, r.Repo())
- }
- }
- return nil
- }
- r, err := ac.Source.GetRepo(rn)
- if err != nil {
- return err
- }
- head, err := r.HEAD()
- if err != nil {
- return err
- }
- tree, err := r.Tree(head, "")
- if err != nil {
- return err
- }
- subpath := strings.Join(ps[1:], "/")
- ents := git.Entries{}
- te, err := tree.TreeEntry(subpath)
- if err == git.ErrRevisionNotExist {
- return ErrFileNotFound
- }
- if err != nil {
- return err
- }
- if te.Type() == "tree" {
- tree, err = tree.SubTree(subpath)
- if err != nil {
- return err
- }
- ents, err = tree.Entries()
- if err != nil {
- return err
- }
- } else {
- ents = append(ents, te)
- }
- ents.Sort()
- for _, ent := range ents {
- fmt.Fprintf(s, "%s\t%d\t %s\n", ent.Mode(), ent.Size(), ent.Name())
- }
- return nil
- },
- }
- return lsCmd
-}
@@ -1,17 +1,16 @@
-package server
+package cmd
import (
"context"
"fmt"
- appCfg "github.com/charmbracelet/soft-serve/config"
- "github.com/charmbracelet/soft-serve/server/cmd"
+ "github.com/charmbracelet/soft-serve/server/config"
+ "github.com/charmbracelet/ssh"
"github.com/charmbracelet/wish"
- "github.com/gliderlabs/ssh"
)
-// softMiddleware is the Soft Serve middleware that handles SSH commands.
-func softMiddleware(ac *appCfg.Config) wish.Middleware {
+// Middleware is the Soft Serve middleware that handles SSH commands.
+func Middleware(cfg *config.Config) wish.Middleware {
return func(sh ssh.Handler) ssh.Handler {
return func(s ssh.Session) {
func() {
@@ -19,16 +18,16 @@ func softMiddleware(ac *appCfg.Config) wish.Middleware {
if active {
return
}
- ctx := context.WithValue(s.Context(), cmd.ConfigCtxKey, ac)
- ctx = context.WithValue(ctx, cmd.SessionCtxKey, s)
+ ctx := context.WithValue(s.Context(), ConfigCtxKey, cfg)
+ ctx = context.WithValue(ctx, SessionCtxKey, s)
use := "ssh"
- port := ac.Port
- if port != 22 {
- use += fmt.Sprintf(" -p%d", port)
+ port := cfg.Backend.ServerPort()
+ if port != "22" {
+ use += fmt.Sprintf(" -p%s", port)
}
- use += fmt.Sprintf(" %s", ac.Host)
- cmd := cmd.RootCommand()
+ use += fmt.Sprintf(" %s", cfg.Backend.ServerHost())
+ cmd := RootCommand()
cmd.Use = use
cmd.CompletionOptions.DisableDefaultCmd = true
cmd.SetIn(s)
@@ -1,23 +0,0 @@
-package cmd
-
-import (
- gitwish "github.com/charmbracelet/wish/git"
- "github.com/spf13/cobra"
-)
-
-// ReloadCommand returns a command that reloads the server configuration.
-func ReloadCommand() *cobra.Command {
- reloadCmd := &cobra.Command{
- Use: "reload",
- Short: "Reloads the configuration",
- RunE: func(cmd *cobra.Command, args []string) error {
- ac, s := fromContext(cmd)
- auth := ac.AuthRepo("config", s.PublicKey())
- if auth < gitwish.AdminAccess {
- return ErrUnauthorized
- }
- return ac.Reload()
- },
- }
- return reloadCmd
-}
@@ -0,0 +1,408 @@
+package cmd
+
+import (
+ "fmt"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "github.com/alecthomas/chroma/lexers"
+ gansi "github.com/charmbracelet/glamour/ansi"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/soft-serve/git"
+ "github.com/charmbracelet/soft-serve/server/backend"
+ "github.com/charmbracelet/soft-serve/ui/common"
+ "github.com/muesli/termenv"
+ "github.com/spf13/cobra"
+)
+
+// RepoCommand is the command for managing repositories.
+func RepoCommand() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "repo COMMAND",
+ Aliases: []string{"repository", "repositories"},
+ Short: "Manage repositories.",
+ }
+ cmd.AddCommand(
+ setCommand(),
+ createCommand(),
+ deleteCommand(),
+ listCommand(),
+ showCommand(),
+ )
+ return cmd
+}
+
+func setCommand() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "set",
+ Short: "Set repository properties.",
+ }
+ cmd.AddCommand(
+ setName(),
+ setDescription(),
+ setPrivate(),
+ setDefaultBranch(),
+ )
+ return cmd
+}
+
+// createCommand is the command for creating a new repository.
+func createCommand() *cobra.Command {
+ var private bool
+ var description string
+ var projectName string
+ cmd := &cobra.Command{
+ Use: "create REPOSITORY",
+ Short: "Create a new repository.",
+ Args: cobra.ExactArgs(1),
+ PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
+ cfg, s := fromContext(cmd)
+ if !cfg.Backend.IsAdmin(s.PublicKey()) {
+ return ErrUnauthorized
+ }
+ return nil
+ },
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cfg, _ := fromContext(cmd)
+ name := args[0]
+ if _, err := cfg.Backend.CreateRepository(name, private); err != nil {
+ return err
+ }
+ return nil
+ },
+ }
+ cmd.Flags().BoolVarP(&private, "private", "p", false, "make the repository private")
+ cmd.Flags().StringVarP(&description, "description", "d", "", "set the repository description")
+ cmd.Flags().StringVarP(&projectName, "project-name", "n", "", "set the project name")
+ return cmd
+}
+
+func deleteCommand() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "delete REPOSITORY",
+ Short: "Delete a repository.",
+ Args: cobra.ExactArgs(1),
+ PersistentPreRunE: checkIfAdmin,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cfg, _ := fromContext(cmd)
+ name := args[0]
+ if err := cfg.Backend.DeleteRepository(name); err != nil {
+ return err
+ }
+ return nil
+ },
+ }
+ return cmd
+}
+
+func checkIfReadable(cmd *cobra.Command, args []string) error {
+ var repo string
+ if len(args) > 0 {
+ repo = args[0]
+ }
+ cfg, s := fromContext(cmd)
+ rn := strings.TrimSuffix(repo, ".git")
+ auth := cfg.Access.AccessLevel(rn, s.PublicKey())
+ if auth < backend.ReadOnlyAccess {
+ return ErrUnauthorized
+ }
+ return nil
+}
+
+func checkIfAdmin(cmd *cobra.Command, args []string) error {
+ cfg, s := fromContext(cmd)
+ if !cfg.Backend.IsAdmin(s.PublicKey()) {
+ return ErrUnauthorized
+ }
+ return nil
+}
+
+func checkIfCollab(cmd *cobra.Command, args []string) error {
+ var repo string
+ if len(args) > 0 {
+ repo = args[0]
+ }
+ cfg, s := fromContext(cmd)
+ rn := strings.TrimSuffix(repo, ".git")
+ auth := cfg.Access.AccessLevel(rn, s.PublicKey())
+ if auth < backend.ReadWriteAccess {
+ return ErrUnauthorized
+ }
+ return nil
+}
+
+func setName() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "name REPOSITORY NEW_NAME",
+ Short: "Set the name for a repository.",
+ Args: cobra.ExactArgs(2),
+ PersistentPreRunE: checkIfAdmin,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cfg, _ := fromContext(cmd)
+ oldName := args[0]
+ newName := args[1]
+ if err := cfg.Backend.RenameRepository(oldName, newName); err != nil {
+ return err
+ }
+ return nil
+ },
+ }
+ return cmd
+}
+
+func setDescription() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "description REPOSITORY DESCRIPTION",
+ Short: "Set the description for a repository.",
+ Args: cobra.MinimumNArgs(2),
+ PersistentPreRunE: checkIfCollab,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cfg, _ := fromContext(cmd)
+ rn := strings.TrimSuffix(args[0], ".git")
+ if err := cfg.Backend.SetDescription(rn, strings.Join(args[1:], " ")); err != nil {
+ return err
+ }
+ return nil
+ },
+ }
+ return cmd
+}
+
+func setPrivate() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "private REPOSITORY [true|false]",
+ Short: "Set a repository to private.",
+ Args: cobra.ExactArgs(2),
+ PersistentPreRunE: checkIfCollab,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cfg, _ := fromContext(cmd)
+ rn := strings.TrimSuffix(args[0], ".git")
+ isPrivate, err := strconv.ParseBool(args[1])
+ if err != nil {
+ return err
+ }
+ if err := cfg.Backend.SetPrivate(rn, isPrivate); err != nil {
+ return err
+ }
+ return nil
+ },
+ }
+ return cmd
+}
+
+func setDefaultBranch() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "default-branch REPOSITORY BRANCH",
+ Short: "Set the default branch for a repository.",
+ Args: cobra.ExactArgs(2),
+ PersistentPreRunE: checkIfAdmin,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cfg, _ := fromContext(cmd)
+ rn := strings.TrimSuffix(args[0], ".git")
+ if err := cfg.Backend.SetDefaultBranch(rn, args[1]); err != nil {
+ return err
+ }
+ return nil
+ },
+ }
+ return cmd
+}
+
+// listCommand returns a command that list file or directory at path.
+func listCommand() *cobra.Command {
+ listCmd := &cobra.Command{
+ Use: "list PATH",
+ Aliases: []string{"ls"},
+ Short: "List file or directory at path.",
+ Args: cobra.RangeArgs(0, 1),
+ PersistentPreRunE: checkIfReadable,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cfg, s := fromContext(cmd)
+ rn := ""
+ path := ""
+ ps := []string{}
+ if len(args) > 0 {
+ path = filepath.Clean(args[0])
+ ps = strings.Split(path, "/")
+ rn = strings.TrimSuffix(ps[0], ".git")
+ auth := cfg.Access.AccessLevel(rn, s.PublicKey())
+ if auth < backend.ReadOnlyAccess {
+ return ErrUnauthorized
+ }
+ }
+ if path == "" || path == "." || path == "/" {
+ repos, err := cfg.Backend.Repositories()
+ if err != nil {
+ return err
+ }
+ for _, r := range repos {
+ if cfg.Access.AccessLevel(r.Name(), s.PublicKey()) >= backend.ReadOnlyAccess {
+ fmt.Fprintln(s, r.Name())
+ }
+ }
+ return nil
+ }
+ rr, err := cfg.Backend.Repository(rn)
+ if err != nil {
+ return err
+ }
+ r, err := rr.Repository()
+ if err != nil {
+ return err
+ }
+ head, err := r.HEAD()
+ if err != nil {
+ if bs, err := r.Branches(); err != nil && len(bs) == 0 {
+ return fmt.Errorf("repository is empty")
+ }
+ return err
+ }
+ tree, err := r.TreePath(head, "")
+ if err != nil {
+ return err
+ }
+ subpath := strings.Join(ps[1:], "/")
+ ents := git.Entries{}
+ te, err := tree.TreeEntry(subpath)
+ if err == git.ErrRevisionNotExist {
+ return ErrFileNotFound
+ }
+ if err != nil {
+ return err
+ }
+ if te.Type() == "tree" {
+ tree, err = tree.SubTree(subpath)
+ if err != nil {
+ return err
+ }
+ ents, err = tree.Entries()
+ if err != nil {
+ return err
+ }
+ } else {
+ ents = append(ents, te)
+ }
+ ents.Sort()
+ for _, ent := range ents {
+ fmt.Fprintf(s, "%s\t%d\t %s\n", ent.Mode(), ent.Size(), ent.Name())
+ }
+ return nil
+ },
+ }
+ return listCmd
+}
+
+var (
+ lineDigitStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("239"))
+ lineBarStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("236"))
+ dirnameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00AAFF"))
+ filenameStyle = lipgloss.NewStyle()
+ filemodeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777"))
+)
+
+// showCommand returns a command that prints the contents of a file.
+func showCommand() *cobra.Command {
+ var linenumber bool
+ var color bool
+
+ showCmd := &cobra.Command{
+ Use: "show PATH",
+ Aliases: []string{"cat"},
+ Short: "Outputs the contents of the file at path.",
+ Args: cobra.ExactArgs(1),
+ PersistentPreRunE: checkIfReadable,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cfg, s := fromContext(cmd)
+ ps := strings.Split(args[0], "/")
+ rn := strings.TrimSuffix(ps[0], ".git")
+ fp := strings.Join(ps[1:], "/")
+ auth := cfg.Access.AccessLevel(rn, s.PublicKey())
+ if auth < backend.ReadOnlyAccess {
+ return ErrUnauthorized
+ }
+ var repo backend.Repository
+ repoExists := false
+ repos, err := cfg.Backend.Repositories()
+ if err != nil {
+ return err
+ }
+ for _, rp := range repos {
+ if rp.Name() == rn {
+ repoExists = true
+ repo = rp
+ break
+ }
+ }
+ if !repoExists {
+ return ErrRepoNotFound
+ }
+ c, _, err := backend.LatestFile(repo, fp)
+ if err != nil {
+ return err
+ }
+ if color {
+ c, err = withFormatting(fp, c)
+ if err != nil {
+ return err
+ }
+ }
+ if linenumber {
+ c = withLineNumber(c, color)
+ }
+ fmt.Fprint(s, c)
+ return nil
+ },
+ }
+ showCmd.Flags().BoolVarP(&linenumber, "linenumber", "l", false, "Print line numbers")
+ showCmd.Flags().BoolVarP(&color, "color", "c", false, "Colorize output")
+
+ return showCmd
+}
+
+func withLineNumber(s string, color bool) string {
+ lines := strings.Split(s, "\n")
+ // NB: len() is not a particularly safe way to count string width (because
+ // it's counting bytes instead of runes) but in this case it's okay
+ // because we're only dealing with digits, which are one byte each.
+ mll := len(fmt.Sprintf("%d", len(lines)))
+ for i, l := range lines {
+ digit := fmt.Sprintf("%*d", mll, i+1)
+ bar := "β"
+ if color {
+ digit = lineDigitStyle.Render(digit)
+ bar = lineBarStyle.Render(bar)
+ }
+ if i < len(lines)-1 || len(l) != 0 {
+ // If the final line was a newline we'll get an empty string for
+ // the final line, so drop the newline altogether.
+ lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l)
+ }
+ }
+ return strings.Join(lines, "\n")
+}
+
+func withFormatting(p, c string) (string, error) {
+ zero := uint(0)
+ lang := ""
+ lexer := lexers.Match(p)
+ if lexer != nil && lexer.Config() != nil {
+ lang = lexer.Config().Name
+ }
+ formatter := &gansi.CodeBlockElement{
+ Code: c,
+ Language: lang,
+ }
+ r := strings.Builder{}
+ styles := common.StyleConfig()
+ styles.CodeBlock.Margin = &zero
+ rctx := gansi.NewRenderContext(gansi.Options{
+ Styles: styles,
+ ColorProfile: termenv.TrueColor,
+ })
+ err := formatter.Render(&r, rctx)
+ if err != nil {
+ return "", err
+ }
+ return r.String(), nil
+}
@@ -1,58 +1,93 @@
package config
import (
- glog "log"
- "path/filepath"
-
"github.com/caarlos0/env/v6"
"github.com/charmbracelet/log"
+ "github.com/charmbracelet/soft-serve/server/backend"
+ "github.com/charmbracelet/soft-serve/server/backend/file"
)
-// Callbacks provides an interface that can be used to run callbacks on different events.
-type Callbacks interface {
- Tui(action string)
- Push(repo string)
- Fetch(repo string)
+// SSHConfig is the configuration for the SSH server.
+type SSHConfig struct {
+ // ListenAddr is the address on which the SSH server will listen.
+ ListenAddr string `env:"LISTEN_ADDR" envDefault:":23231"`
+
+ // KeyPath is the path to the SSH server's private key.
+ KeyPath string `env:"KEY_PATH" envDefault:"soft_serve"`
+
+ // MaxTimeout is the maximum number of seconds a connection can take.
+ MaxTimeout int `env:"MAX_TIMEOUT" envDefault:"0"`
+
+ // IdleTimeout is the number of seconds a connection can be idle before it is closed.
+ IdleTimeout int `env:"IDLE_TIMEOUT" envDefault:"120"`
+}
+
+// GitConfig is the Git daemon configuration for the server.
+type GitConfig struct {
+ // ListenAddr is the address on which the Git daemon will listen.
+ ListenAddr string `env:"LISTEN_ADDR" envDefault:":9418"`
+
+ // MaxTimeout is the maximum number of seconds a connection can take.
+ MaxTimeout int `env:"MAX_TIMEOUT" envDefault:"0"`
+
+ // IdleTimeout is the number of seconds a connection can be idle before it is closed.
+ IdleTimeout int `env:"IDLE_TIMEOUT" envDefault:"3"`
+
+ // MaxConnections is the maximum number of concurrent connections.
+ MaxConnections int `env:"MAX_CONNECTIONS" envDefault:"32"`
}
// Config is the configuration for Soft Serve.
type Config struct {
- BindAddr string `env:"SOFT_SERVE_BIND_ADDRESS" envDefault:""`
- Host string `env:"SOFT_SERVE_HOST" envDefault:"localhost"`
- Port int `env:"SOFT_SERVE_PORT" envDefault:"23231"`
- KeyPath string `env:"SOFT_SERVE_KEY_PATH"`
- RepoPath string `env:"SOFT_SERVE_REPO_PATH" envDefault:".repos"`
- Debug bool `env:"SOFT_SERVE_DEBUG" envDefault:"false"`
- InitialAdminKeys []string `env:"SOFT_SERVE_INITIAL_ADMIN_KEY" envSeparator:"\n"`
- Callbacks Callbacks
- ErrorLog *glog.Logger
+ // SSH is the configuration for the SSH server.
+ SSH SSHConfig `envPrefix:"SSH_"`
+
+ // Git is the configuration for the Git daemon.
+ Git GitConfig `envPrefix:"GIT_"`
+
+ // InitialAdminKeys is a list of public keys that will be added to the list of admins.
+ InitialAdminKeys []string `env:"INITIAL_ADMIN_KEY" envSeparator:"\n"`
+
+ // DataPath is the path to the directory where Soft Serve will store its data.
+ DataPath string `env:"DATA_PATH" envDefault:"data"`
+
+ // Debug enables debug logging.
+ Debug bool `env:"DEBUG" envDefault:"false"`
+
+ // Backend is the Git backend to use.
+ Backend backend.Backend
+
+ // Access is the access control backend to use.
+ Access backend.AccessMethod
}
// DefaultConfig returns a Config with the values populated with the defaults
// or specified environment variables.
func DefaultConfig() *Config {
- cfg := &Config{ErrorLog: log.StandardLog(log.StandardLogOptions{ForceLevel: log.ErrorLevel})}
- if err := env.Parse(cfg); err != nil {
+ cfg := &Config{}
+ if err := env.Parse(cfg, env.Options{
+ Prefix: "SOFT_SERVE_",
+ }); err != nil {
log.Fatal(err)
}
if cfg.Debug {
log.SetLevel(log.DebugLevel)
}
- if cfg.KeyPath == "" {
- // NB: cross-platform-compatible path
- cfg.KeyPath = filepath.Join(".ssh", "soft_serve_server_ed25519")
+ fb, err := file.NewFileBackend(cfg.DataPath)
+ if err != nil {
+ log.Fatal(err)
}
- return cfg.WithCallbacks(nil)
+ return cfg.WithBackend(fb).WithAccessMethod(fb)
}
-// WithCallbacks applies the given Callbacks to the configuration.
-func (c *Config) WithCallbacks(callbacks Callbacks) *Config {
- c.Callbacks = callbacks
+// WithBackend sets the backend for the configuration.
+func (c *Config) WithBackend(backend backend.Backend) *Config {
+ c.Backend = backend
return c
}
-// WithErrorLogger sets the error logger for the configuration.
-func (c *Config) WithErrorLogger(logger *glog.Logger) *Config {
- c.ErrorLog = logger
+// WithAccessMethod sets the access control method for the configuration.
+func (c *Config) WithAccessMethod(access backend.AccessMethod) *Config {
+ c.Access = access
return c
}
@@ -0,0 +1,299 @@
+package server
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "log"
+ "net"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/charmbracelet/soft-serve/server/backend"
+ "github.com/charmbracelet/soft-serve/server/config"
+ "github.com/go-git/go-git/v5/plumbing/format/pktline"
+)
+
+// ErrServerClosed indicates that the server has been closed.
+var ErrServerClosed = errors.New("git: Server closed")
+
+// connections synchronizes access to to a net.Conn pool.
+type connections struct {
+ m map[net.Conn]struct{}
+ mu sync.Mutex
+}
+
+func (m *connections) Add(c net.Conn) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.m[c] = struct{}{}
+}
+
+func (m *connections) Close(c net.Conn) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ _ = c.Close()
+ delete(m.m, c)
+}
+
+func (m *connections) Size() int {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return len(m.m)
+}
+
+func (m *connections) CloseAll() {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ for c := range m.m {
+ _ = c.Close()
+ delete(m.m, c)
+ }
+}
+
+// GitDaemon represents a Git daemon.
+type GitDaemon struct {
+ listener net.Listener
+ addr string
+ finished chan struct{}
+ conns connections
+ cfg *config.Config
+ wg sync.WaitGroup
+ once sync.Once
+}
+
+// NewDaemon returns a new Git daemon.
+func NewGitDaemon(cfg *config.Config) (*GitDaemon, error) {
+ addr := cfg.Git.ListenAddr
+ d := &GitDaemon{
+ addr: addr,
+ finished: make(chan struct{}, 1),
+ cfg: cfg,
+ conns: connections{m: make(map[net.Conn]struct{})},
+ }
+ listener, err := net.Listen("tcp", d.addr)
+ if err != nil {
+ return nil, err
+ }
+ d.listener = listener
+ return d, nil
+}
+
+// Start starts the Git TCP daemon.
+func (d *GitDaemon) Start() error {
+ defer d.listener.Close() // nolint: errcheck
+
+ d.wg.Add(1)
+ defer d.wg.Done()
+
+ var tempDelay time.Duration
+ for {
+ conn, err := d.listener.Accept()
+ if err != nil {
+ select {
+ case <-d.finished:
+ return ErrServerClosed
+ default:
+ log.Printf("git: error accepting connection: %v", err)
+ }
+ if ne, ok := err.(net.Error); ok && ne.Temporary() {
+ if tempDelay == 0 {
+ tempDelay = 5 * time.Millisecond
+ } else {
+ tempDelay *= 2
+ }
+ if max := 1 * time.Second; tempDelay > max {
+ tempDelay = max
+ }
+ time.Sleep(tempDelay)
+ continue
+ }
+ return err
+ }
+
+ // Close connection if there are too many open connections.
+ if d.conns.Size()+1 >= d.cfg.Git.MaxConnections {
+ log.Printf("git: max connections reached, closing %s", conn.RemoteAddr())
+ fatal(conn, ErrMaxConnections)
+ continue
+ }
+
+ d.wg.Add(1)
+ go func() {
+ d.handleClient(conn)
+ d.wg.Done()
+ }()
+ }
+}
+
+func fatal(c net.Conn, err error) {
+ WritePktline(c, err)
+ if err := c.Close(); err != nil {
+ log.Printf("git: error closing connection: %v", err)
+ }
+}
+
+// handleClient handles a git protocol client.
+func (d *GitDaemon) handleClient(conn net.Conn) {
+ ctx, cancel := context.WithCancel(context.Background())
+ idleTimeout := time.Duration(d.cfg.Git.IdleTimeout) * time.Second
+ c := &serverConn{
+ Conn: conn,
+ idleTimeout: idleTimeout,
+ closeCanceler: cancel,
+ }
+ if d.cfg.Git.MaxTimeout > 0 {
+ dur := time.Duration(d.cfg.Git.MaxTimeout) * time.Second
+ c.maxDeadline = time.Now().Add(dur)
+ }
+ d.conns.Add(c)
+ defer func() {
+ d.conns.Close(c)
+ }()
+
+ readc := make(chan struct{}, 1)
+ s := pktline.NewScanner(c)
+ go func() {
+ if !s.Scan() {
+ if err := s.Err(); err != nil {
+ if nerr, ok := err.(net.Error); ok && nerr.Timeout() {
+ fatal(c, ErrTimeout)
+ } else {
+ log.Printf("git: error scanning pktline: %v", err)
+ fatal(c, ErrSystemMalfunction)
+ }
+ }
+ return
+ }
+ readc <- struct{}{}
+ }()
+
+ select {
+ case <-ctx.Done():
+ if err := ctx.Err(); err != nil {
+ log.Printf("git: connection context error: %v", err)
+ }
+ return
+ case <-readc:
+ line := s.Bytes()
+ split := bytes.SplitN(line, []byte{' '}, 2)
+ if len(split) != 2 {
+ fatal(c, ErrInvalidRequest)
+ return
+ }
+
+ var gitPack func(io.Reader, io.Writer, io.Writer, string) error
+ var repo string
+ cmd := string(split[0])
+ switch cmd {
+ case UploadPackBin:
+ gitPack = UploadPack
+ case UploadArchiveBin:
+ gitPack = UploadArchive
+ default:
+ fatal(c, ErrInvalidRequest)
+ return
+ }
+
+ opts := bytes.Split(split[1], []byte{'\x00'})
+ if len(opts) == 0 {
+ fatal(c, ErrInvalidRequest)
+ return
+ }
+
+ repo = filepath.Clean(string(opts[0]))
+ log.Printf("git: connect %s %s %s", c.RemoteAddr(), cmd, repo)
+ defer log.Printf("git: disconnect %s %s %s", c.RemoteAddr(), cmd, repo)
+ repo = strings.TrimPrefix(repo, "/")
+ auth := d.cfg.Access.AccessLevel(strings.TrimSuffix(repo, ".git"), nil)
+ if auth < backend.ReadOnlyAccess {
+ fatal(c, ErrNotAuthed)
+ return
+ }
+ // git bare repositories should end in ".git"
+ // https://git-scm.com/docs/gitrepository-layout
+ repo = strings.TrimSuffix(repo, ".git") + ".git"
+ // FIXME: determine repositories path
+ repoDir := filepath.Join(d.cfg.DataPath, "repos", repo)
+ if err := gitPack(c, c, c, repoDir); err != nil {
+ fatal(c, err)
+ return
+ }
+ }
+}
+
+// Close closes the underlying listener.
+func (d *GitDaemon) Close() error {
+ d.once.Do(func() { close(d.finished) })
+ err := d.listener.Close()
+ d.conns.CloseAll()
+ return err
+}
+
+// Shutdown gracefully shuts down the daemon.
+func (d *GitDaemon) Shutdown(ctx context.Context) error {
+ d.once.Do(func() { close(d.finished) })
+ err := d.listener.Close()
+ finished := make(chan struct{}, 1)
+ go func() {
+ d.wg.Wait()
+ finished <- struct{}{}
+ }()
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-finished:
+ return err
+ }
+}
+
+type serverConn struct {
+ net.Conn
+
+ idleTimeout time.Duration
+ maxDeadline time.Time
+ closeCanceler context.CancelFunc
+}
+
+func (c *serverConn) Write(p []byte) (n int, err error) {
+ c.updateDeadline()
+ n, err = c.Conn.Write(p)
+ if _, isNetErr := err.(net.Error); isNetErr && c.closeCanceler != nil {
+ c.closeCanceler()
+ }
+ return
+}
+
+func (c *serverConn) Read(b []byte) (n int, err error) {
+ c.updateDeadline()
+ n, err = c.Conn.Read(b)
+ if _, isNetErr := err.(net.Error); isNetErr && c.closeCanceler != nil {
+ c.closeCanceler()
+ }
+ return
+}
+
+func (c *serverConn) Close() (err error) {
+ err = c.Conn.Close()
+ if c.closeCanceler != nil {
+ c.closeCanceler()
+ }
+ return
+}
+
+func (c *serverConn) updateDeadline() {
+ switch {
+ case c.idleTimeout > 0:
+ idleDeadline := time.Now().Add(c.idleTimeout)
+ if idleDeadline.Unix() < c.maxDeadline.Unix() || c.maxDeadline.IsZero() {
+ c.Conn.SetDeadline(idleDeadline)
+ return
+ }
+ fallthrough
+ default:
+ c.Conn.SetDeadline(c.maxDeadline)
+ }
+}
@@ -0,0 +1,95 @@
+package server
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/charmbracelet/soft-serve/server/config"
+ "github.com/go-git/go-git/v5/plumbing/format/pktline"
+)
+
+var testDaemon *GitDaemon
+
+func TestMain(m *testing.M) {
+ tmp, err := os.MkdirTemp("", "soft-serve-test")
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer os.RemoveAll(tmp)
+ os.Setenv("SOFT_SERVE_DATA_PATH", tmp)
+ os.Setenv("SOFT_SERVE_GIT_MAX_CONNECTIONS", "3")
+ os.Setenv("SOFT_SERVE_GIT_MAX_TIMEOUT", "100")
+ os.Setenv("SOFT_SERVE_GIT_IDLE_TIMEOUT", "1")
+ os.Setenv("SOFT_SERVE_GIT_LISTEN_ADDR", fmt.Sprintf(":%d", randomPort()))
+ cfg := config.DefaultConfig()
+ d, err := NewGitDaemon(cfg)
+ if err != nil {
+ log.Fatal(err)
+ }
+ testDaemon = d
+ go func() {
+ if err := d.Start(); err != ErrServerClosed {
+ log.Fatal(err)
+ }
+ }()
+ code := m.Run()
+ os.Unsetenv("SOFT_SERVE_DATA_PATH")
+ os.Unsetenv("SOFT_SERVE_GIT_MAX_CONNECTIONS")
+ os.Unsetenv("SOFT_SERVE_GIT_MAX_TIMEOUT")
+ os.Unsetenv("SOFT_SERVE_GIT_IDLE_TIMEOUT")
+ os.Unsetenv("SOFT_SERVE_GIT_LISTEN_ADDR")
+ _ = d.Close()
+ os.Exit(code)
+}
+
+func TestIdleTimeout(t *testing.T) {
+ c, err := net.Dial("tcp", testDaemon.addr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ time.Sleep(2 * time.Second)
+ out, err := readPktline(c)
+ if err != nil && !errors.Is(err, io.EOF) {
+ t.Fatalf("expected nil, got error: %v", err)
+ }
+ if out != ErrTimeout.Error() {
+ t.Fatalf("expected %q error, got %q", ErrTimeout, out)
+ }
+}
+
+func TestInvalidRepo(t *testing.T) {
+ c, err := net.Dial("tcp", testDaemon.addr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := pktline.NewEncoder(c).EncodeString("git-upload-pack /test.git\x00"); err != nil {
+ t.Fatalf("expected nil, got error: %v", err)
+ }
+ out, err := readPktline(c)
+ if err != nil {
+ t.Fatalf("expected nil, got error: %v", err)
+ }
+ if out != ErrInvalidRepo.Error() {
+ t.Fatalf("expected %q error, got %q", ErrInvalidRepo, out)
+ }
+}
+
+func readPktline(c net.Conn) (string, error) {
+ buf, err := io.ReadAll(c)
+ if err != nil {
+ return "", err
+ }
+ pktout := pktline.NewScanner(bytes.NewReader(buf))
+ if !pktout.Scan() {
+ return "", pktout.Err()
+ }
+ return strings.TrimSpace(string(pktout.Bytes())), nil
+}
@@ -0,0 +1,162 @@
+package server
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "os"
+ "path/filepath"
+
+ "github.com/charmbracelet/soft-serve/git"
+ "github.com/go-git/go-git/v5/plumbing/format/pktline"
+)
+
+var (
+
+ // ErrNotAuthed represents unauthorized access.
+ ErrNotAuthed = errors.New("you are not authorized to do this")
+
+ // ErrSystemMalfunction represents a general system error returned to clients.
+ ErrSystemMalfunction = errors.New("something went wrong")
+
+ // ErrInvalidRepo represents an attempt to access a non-existent repo.
+ ErrInvalidRepo = errors.New("invalid repo")
+
+ // ErrInvalidRequest represents an invalid request.
+ ErrInvalidRequest = errors.New("invalid request")
+
+ // ErrMaxConnections represents a maximum connection limit being reached.
+ ErrMaxConnections = errors.New("too many connections, try again later")
+
+ // ErrTimeout is returned when the maximum read timeout is exceeded.
+ ErrTimeout = errors.New("I/O timeout reached")
+)
+
+// Git protocol commands.
+const (
+ ReceivePackBin = "git-receive-pack"
+ UploadPackBin = "git-upload-pack"
+ UploadArchiveBin = "git-upload-archive"
+)
+
+// UploadPack runs the git upload-pack protocol against the provided repo.
+func UploadPack(in io.Reader, out io.Writer, er io.Writer, repoDir string) error {
+ exists, err := fileExists(repoDir)
+ if !exists {
+ return ErrInvalidRepo
+ }
+ if err != nil {
+ return err
+ }
+ return RunGit(in, out, er, "", UploadPackBin[4:], repoDir)
+}
+
+// UploadArchive runs the git upload-archive protocol against the provided repo.
+func UploadArchive(in io.Reader, out io.Writer, er io.Writer, repoDir string) error {
+ exists, err := fileExists(repoDir)
+ if !exists {
+ return ErrInvalidRepo
+ }
+ if err != nil {
+ return err
+ }
+ return RunGit(in, out, er, "", UploadArchiveBin[4:], repoDir)
+}
+
+// ReceivePack runs the git receive-pack protocol against the provided repo.
+func ReceivePack(in io.Reader, out io.Writer, er io.Writer, repoDir string) error {
+ if err := ensureRepo(repoDir, ""); err != nil {
+ return err
+ }
+ if err := RunGit(in, out, er, "", ReceivePackBin[4:], repoDir); err != nil {
+ return err
+ }
+ return ensureDefaultBranch(in, out, er, repoDir)
+}
+
+// RunGit runs a git command in the given repo.
+func RunGit(in io.Reader, out io.Writer, err io.Writer, dir string, args ...string) error {
+ c := git.NewCommand(args...)
+ return c.RunInDirWithOptions(dir, git.RunInDirOptions{
+ Stdin: in,
+ Stdout: out,
+ Stderr: err,
+ })
+}
+
+// WritePktline encodes and writes a pktline to the given writer.
+func WritePktline(w io.Writer, v ...interface{}) {
+ msg := fmt.Sprintln(v...)
+ pkt := pktline.NewEncoder(w)
+ if err := pkt.EncodeString(msg); err != nil {
+ log.Printf("git: error writing pkt-line message: %s", err)
+ }
+ if err := pkt.Flush(); err != nil {
+ log.Printf("git: error flushing pkt-line message: %s", err)
+ }
+}
+
+func fileExists(path string) (bool, error) {
+ _, err := os.Stat(path)
+ if err == nil {
+ return true, nil
+ }
+ if os.IsNotExist(err) {
+ return false, nil
+ }
+ return true, err
+}
+
+func ensureRepo(dir string, repo string) error {
+ exists, err := fileExists(dir)
+ if err != nil {
+ return err
+ }
+ if !exists {
+ err = os.MkdirAll(dir, os.ModeDir|os.FileMode(0700))
+ if err != nil {
+ return err
+ }
+ }
+ rp := filepath.Join(dir, repo)
+ exists, err = fileExists(rp)
+ if err != nil {
+ return err
+ }
+ // FIXME: use backend.CreateRepository
+ if !exists {
+ _, err := git.Init(rp, true)
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func ensureDefaultBranch(in io.Reader, out io.Writer, er io.Writer, repoPath string) error {
+ r, err := git.Open(repoPath)
+ if err != nil {
+ return err
+ }
+ brs, err := r.Branches()
+ if err != nil {
+ return err
+ }
+ if len(brs) == 0 {
+ return fmt.Errorf("no branches found")
+ }
+ // Rename the default branch to the first branch available
+ _, err = r.HEAD()
+ if err == git.ErrReferenceNotExist {
+ // FIXME: use backend.SetDefaultBranch
+ err = RunGit(in, out, er, repoPath, "branch", "-M", brs[0])
+ if err != nil {
+ return err
+ }
+ }
+ if err != nil && err != git.ErrReferenceNotExist {
+ return err
+ }
+ return nil
+}
@@ -0,0 +1,55 @@
+package daemon
+
+import (
+ "context"
+ "net"
+ "time"
+)
+
+type serverConn struct {
+ net.Conn
+
+ idleTimeout time.Duration
+ maxDeadline time.Time
+ closeCanceler context.CancelFunc
+}
+
+func (c *serverConn) Write(p []byte) (n int, err error) {
+ c.updateDeadline()
+ n, err = c.Conn.Write(p)
+ if _, isNetErr := err.(net.Error); isNetErr && c.closeCanceler != nil {
+ c.closeCanceler()
+ }
+ return
+}
+
+func (c *serverConn) Read(b []byte) (n int, err error) {
+ c.updateDeadline()
+ n, err = c.Conn.Read(b)
+ if _, isNetErr := err.(net.Error); isNetErr && c.closeCanceler != nil {
+ c.closeCanceler()
+ }
+ return
+}
+
+func (c *serverConn) Close() (err error) {
+ err = c.Conn.Close()
+ if c.closeCanceler != nil {
+ c.closeCanceler()
+ }
+ return
+}
+
+func (c *serverConn) updateDeadline() {
+ switch {
+ case c.idleTimeout > 0:
+ idleDeadline := time.Now().Add(c.idleTimeout)
+ if idleDeadline.Unix() < c.maxDeadline.Unix() || c.maxDeadline.IsZero() {
+ c.Conn.SetDeadline(idleDeadline)
+ return
+ }
+ fallthrough
+ default:
+ c.Conn.SetDeadline(c.maxDeadline)
+ }
+}
@@ -1,38 +0,0 @@
-package server
-
-import (
- "os"
- "testing"
-
- "github.com/charmbracelet/soft-serve/config"
- sconfig "github.com/charmbracelet/soft-serve/server/config"
- "github.com/charmbracelet/wish/testsession"
- "github.com/gliderlabs/ssh"
- "github.com/matryer/is"
-)
-
-var ()
-
-func TestMiddleware(t *testing.T) {
- t.Cleanup(func() {
- os.RemoveAll("testmiddleware")
- })
- is := is.New(t)
- appCfg, err := config.NewConfig(&sconfig.Config{
- Host: "localhost",
- Port: 22223,
- RepoPath: "testmiddleware/repos",
- KeyPath: "testmiddleware/key",
- })
- is.NoErr(err)
- _ = testsession.New(t, &ssh.Server{
- Handler: softMiddleware(appCfg)(func(s ssh.Session) {
- t.Run("TestCatConfig", func(t *testing.T) {
- _, err := s.Write([]byte("cat config/config.json"))
- if err == nil {
- t.Errorf("Expected error, got nil")
- }
- })
- }),
- }, nil)
-}
@@ -2,29 +2,26 @@ package server
import (
"context"
- "fmt"
- "net"
- "path/filepath"
- "strings"
"github.com/charmbracelet/log"
- appCfg "github.com/charmbracelet/soft-serve/config"
+ "github.com/charmbracelet/soft-serve/server/backend"
"github.com/charmbracelet/soft-serve/server/config"
- "github.com/charmbracelet/wish"
- bm "github.com/charmbracelet/wish/bubbletea"
- gm "github.com/charmbracelet/wish/git"
- lm "github.com/charmbracelet/wish/logging"
- rm "github.com/charmbracelet/wish/recover"
- "github.com/gliderlabs/ssh"
- "github.com/muesli/termenv"
+ "github.com/charmbracelet/ssh"
+ "golang.org/x/sync/errgroup"
+)
+
+var (
+ logger = log.WithPrefix("server")
)
// Server is the Soft Serve server.
type Server struct {
- SSHServer *ssh.Server
+ SSHServer *SSHServer
+ GitDaemon *GitDaemon
Config *config.Config
- config *appCfg.Config
+ Backend backend.Backend
+ Access backend.AccessMethod
}
// NewServer returns a new *ssh.Server configured to serve Soft Serve. The SSH
@@ -32,85 +29,66 @@ type Server struct {
// key can be provided with authKey. If authKey is provided, access will be
// restricted to that key. If authKey is not provided, the server will be
// publicly writable until configured otherwise by cloning the `config` repo.
-func NewServer(cfg *config.Config) *Server {
- ac, err := appCfg.NewConfig(cfg)
- if err != nil {
- log.Fatal(err)
+func NewServer(cfg *config.Config) (*Server, error) {
+ var err error
+ srv := &Server{
+ Config: cfg,
+ Backend: cfg.Backend,
+ Access: cfg.Access,
}
- mw := []wish.Middleware{
- rm.MiddlewareWithLogger(
- cfg.ErrorLog,
- softMiddleware(ac),
- bm.MiddlewareWithProgramHandler(SessionHandler(ac), termenv.ANSI256),
- gm.Middleware(cfg.RepoPath, ac),
- // Note: disable pushing to subdirectories as it can create
- // conflicts with existing repos. This only affects the git
- // middleware.
- //
- // This is related to
- // https://github.com/charmbracelet/soft-serve/issues/120
- // https://github.com/charmbracelet/wish/commit/8808de520d3ea21931f13113c6b0b6d0141272d4
- func(sh ssh.Handler) ssh.Handler {
- return func(s ssh.Session) {
- cmds := s.Command()
- if len(cmds) == 2 && strings.HasPrefix(cmds[0], "git") {
- repo := strings.TrimSuffix(strings.TrimPrefix(cmds[1], "/"), "/")
- repo = filepath.Clean(repo)
- if n := strings.Count(repo, "/"); n != 0 {
- wish.Fatalln(s, fmt.Errorf("invalid repo path: subdirectories not allowed"))
- return
- }
- }
- sh(s)
- }
- },
- lm.MiddlewareWithLogger(log.StandardLog(log.StandardLogOptions{ForceLevel: log.DebugLevel})),
- ),
- }
- s, err := wish.NewServer(
- ssh.PublicKeyAuth(ac.PublicKeyHandler),
- ssh.KeyboardInteractiveAuth(ac.KeyboardInteractiveHandler),
- wish.WithAddress(fmt.Sprintf("%s:%d", cfg.BindAddr, cfg.Port)),
- wish.WithHostKeyPath(cfg.KeyPath),
- wish.WithMiddleware(mw...),
- )
+ srv.SSHServer, err = NewSSHServer(cfg)
if err != nil {
- log.Fatal(err)
+ return nil, err
}
- return &Server{
- SSHServer: s,
- Config: cfg,
- config: ac,
+
+ srv.GitDaemon, err = NewGitDaemon(cfg)
+ if err != nil {
+ return nil, err
}
-}
-// Reload reloads the server configuration.
-func (srv *Server) Reload() error {
- return srv.config.Reload()
+ return srv, nil
}
// Start starts the SSH server.
-func (srv *Server) Start() error {
- if err := srv.SSHServer.ListenAndServe(); err != ssh.ErrServerClosed {
- return err
- }
- return nil
-}
-
-// Serve serves the SSH server using the provided listener.
-func (srv *Server) Serve(l net.Listener) error {
- if err := srv.SSHServer.Serve(l); err != ssh.ErrServerClosed {
- return err
- }
- return nil
+func (s *Server) Start() error {
+ var errg errgroup.Group
+ errg.Go(func() error {
+ log.Print("Starting Git daemon", "addr", s.Config.Git.ListenAddr)
+ if err := s.GitDaemon.Start(); err != ErrServerClosed {
+ return err
+ }
+ return nil
+ })
+ errg.Go(func() error {
+ log.Print("Starting SSH server", "addr", s.Config.SSH.ListenAddr)
+ if err := s.SSHServer.ListenAndServe(); err != ssh.ErrServerClosed {
+ return err
+ }
+ return nil
+ })
+ return errg.Wait()
}
// Shutdown lets the server gracefully shutdown.
-func (srv *Server) Shutdown(ctx context.Context) error {
- return srv.SSHServer.Shutdown(ctx)
+func (s *Server) Shutdown(ctx context.Context) error {
+ var errg errgroup.Group
+ errg.Go(func() error {
+ return s.GitDaemon.Shutdown(ctx)
+ })
+ errg.Go(func() error {
+ return s.SSHServer.Shutdown(ctx)
+ })
+ return errg.Wait()
}
// Close closes the SSH server.
-func (srv *Server) Close() error {
- return srv.SSHServer.Close()
+func (s *Server) Close() error {
+ var errg errgroup.Group
+ errg.Go(func() error {
+ return s.SSHServer.Close()
+ })
+ errg.Go(func() error {
+ return s.GitDaemon.Close()
+ })
+ return errg.Wait()
}
@@ -2,116 +2,64 @@ package server
import (
"fmt"
- "os"
+ "net"
"path/filepath"
+ "strings"
"testing"
"github.com/charmbracelet/keygen"
+ "github.com/charmbracelet/soft-serve/server/backend/noop"
"github.com/charmbracelet/soft-serve/server/config"
- "github.com/gliderlabs/ssh"
- "github.com/go-git/go-git/v5"
- gconfig "github.com/go-git/go-git/v5/config"
- "github.com/go-git/go-git/v5/plumbing/object"
- gssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
+ "github.com/charmbracelet/ssh"
"github.com/matryer/is"
- cssh "golang.org/x/crypto/ssh"
+ gossh "golang.org/x/crypto/ssh"
)
-var (
- testdata = "testdata"
- cfg = &config.Config{
- BindAddr: "",
- Host: "localhost",
- Port: 22222,
- RepoPath: fmt.Sprintf("%s/repos", testdata),
- KeyPath: fmt.Sprintf("%s/key", testdata),
- }
- pkPath = ""
-)
-
-func TestServer(t *testing.T) {
- t.Cleanup(func() {
- os.RemoveAll(testdata)
- })
- is := is.New(t)
- _, pkPath = createKeyPair(t)
- s := setupServer(t)
- err := s.Reload()
- is.NoErr(err)
- t.Run("TestPushRepo", testPushRepo)
- t.Run("TestCloneRepo", testCloneRepo)
-}
-
-func testPushRepo(t *testing.T) {
- is := is.New(t)
- rp := t.TempDir()
- r, err := git.PlainInit(rp, false)
- is.NoErr(err)
- wt, err := r.Worktree()
- is.NoErr(err)
- _, err = wt.Filesystem.Create("testfile")
- is.NoErr(err)
- _, err = wt.Add("testfile")
- is.NoErr(err)
- author := &object.Signature{
- Name: "test",
- Email: "",
- }
- _, err = wt.Commit("test commit", &git.CommitOptions{
- All: true,
- Author: author,
- Committer: author,
- })
- is.NoErr(err)
- _, err = r.CreateRemote(&gconfig.RemoteConfig{
- Name: "origin",
- URLs: []string{fmt.Sprintf("ssh://%s:%d/%s", cfg.Host, cfg.Port, "testrepo")},
- })
- auth, err := gssh.NewPublicKeysFromFile("git", pkPath, "")
- is.NoErr(err)
- auth.HostKeyCallbackHelper = gssh.HostKeyCallbackHelper{
- HostKeyCallback: cssh.InsecureIgnoreHostKey(),
- }
- err = r.Push(&git.PushOptions{
- RemoteName: "origin",
- Auth: auth,
- })
- is.NoErr(err)
+func randomPort() int {
+ addr, _ := net.Listen("tcp", ":0") //nolint:gosec
+ _ = addr.Close()
+ return addr.Addr().(*net.TCPAddr).Port
}
-func testCloneRepo(t *testing.T) {
- is := is.New(t)
- auth, err := gssh.NewPublicKeysFromFile("git", pkPath, "")
- is.NoErr(err)
- auth.HostKeyCallbackHelper = gssh.HostKeyCallbackHelper{
- HostKeyCallback: cssh.InsecureIgnoreHostKey(),
+func setupServer(tb testing.TB) (*Server, *config.Config, string) {
+ tb.Helper()
+ tb.Log("creating keypair")
+ pub, pkPath := createKeyPair(tb)
+ dp := tb.TempDir()
+ sshPort := fmt.Sprintf(":%d", randomPort())
+ tb.Setenv("SOFT_SERVE_DATA_PATH", dp)
+ tb.Setenv("SOFT_SERVE_INITIAL_ADMIN_KEY", authorizedKey(pub))
+ tb.Setenv("SOFT_SERVE_SSH_LISTEN_ADDR", sshPort)
+ tb.Setenv("SOFT_SERVE_GIT_LISTEN_ADDR", fmt.Sprintf(":%d", randomPort()))
+ cfg := config.DefaultConfig()
+ nop := &noop.Noop{Port: sshPort[1:]}
+ tb.Log("configuring server")
+ cfg = cfg.WithBackend(nop).WithAccessMethod(nop)
+ s, err := NewServer(cfg)
+ if err != nil {
+ tb.Fatal(err)
}
- dst := t.TempDir()
- _, err = git.PlainClone(dst, false, &git.CloneOptions{
- URL: fmt.Sprintf("ssh://%s:%d/config", cfg.Host, cfg.Port),
- Auth: auth,
- })
- is.NoErr(err)
-}
-
-func setupServer(t *testing.T) *Server {
- s := NewServer(cfg)
go func() {
+ tb.Log("starting server")
s.Start()
}()
- t.Cleanup(func() {
+ tb.Cleanup(func() {
s.Close()
})
- return s
+ return s, cfg, pkPath
}
-func createKeyPair(t *testing.T) (ssh.PublicKey, string) {
- is := is.New(t)
- t.Helper()
- keyDir := t.TempDir()
+func createKeyPair(tb testing.TB) (ssh.PublicKey, string) {
+ tb.Helper()
+ is := is.New(tb)
+ keyDir := tb.TempDir()
kp, err := keygen.NewWithWrite(filepath.Join(keyDir, "id"), nil, keygen.Ed25519)
is.NoErr(err)
pubkey, _, _, _, err := ssh.ParseAuthorizedKey(kp.PublicKey())
is.NoErr(err)
return pubkey, filepath.Join(keyDir, "id_ed25519")
}
+
+func authorizedKey(pk ssh.PublicKey) string {
+ return strings.TrimSpace(string(gossh.MarshalAuthorizedKey(pk)))
+}
@@ -5,21 +5,18 @@ import (
"github.com/aymanbagabas/go-osc52"
tea "github.com/charmbracelet/bubbletea"
- appCfg "github.com/charmbracelet/soft-serve/config"
+ "github.com/charmbracelet/soft-serve/server/backend"
cm "github.com/charmbracelet/soft-serve/server/cmd"
+ "github.com/charmbracelet/soft-serve/server/config"
"github.com/charmbracelet/soft-serve/ui"
"github.com/charmbracelet/soft-serve/ui/common"
- "github.com/charmbracelet/soft-serve/ui/keymap"
- "github.com/charmbracelet/soft-serve/ui/styles"
+ "github.com/charmbracelet/ssh"
"github.com/charmbracelet/wish"
bm "github.com/charmbracelet/wish/bubbletea"
- gm "github.com/charmbracelet/wish/git"
- "github.com/gliderlabs/ssh"
- zone "github.com/lrstanley/bubblezone"
)
// SessionHandler is the soft-serve bubbletea ssh session handler.
-func SessionHandler(ac *appCfg.Config) bm.ProgramHandler {
+func SessionHandler(cfg *config.Config) bm.ProgramHandler {
return func(s ssh.Session) *tea.Program {
pty, _, active := s.Pty()
if !active {
@@ -29,32 +26,18 @@ func SessionHandler(ac *appCfg.Config) bm.ProgramHandler {
initialRepo := ""
if len(cmd) == 1 {
initialRepo = cmd[0]
- auth := ac.AuthRepo(initialRepo, s.PublicKey())
- if auth < gm.ReadOnlyAccess {
+ auth := cfg.Access.AccessLevel(initialRepo, s.PublicKey())
+ if auth < backend.ReadOnlyAccess {
wish.Fatalln(s, cm.ErrUnauthorized)
return nil
}
}
- if ac.Cfg.Callbacks != nil {
- ac.Cfg.Callbacks.Tui("new session")
- }
envs := s.Environ()
envs = append(envs, fmt.Sprintf("TERM=%s", pty.Term))
output := osc52.NewOutput(s, envs)
- c := common.Common{
- Copy: output,
- Styles: styles.DefaultStyles(),
- KeyMap: keymap.DefaultKeyMap(),
- Width: pty.Window.Width,
- Height: pty.Window.Height,
- Zone: zone.New(),
- }
- m := ui.New(
- ac,
- s,
- c,
- initialRepo,
- )
+ c := common.NewCommon(s.Context(), output, pty.Window.Width, pty.Window.Height)
+ c.SetValue(common.ConfigKey, cfg)
+ m := ui.New(c, initialRepo)
p := tea.NewProgram(m,
tea.WithInput(s),
tea.WithOutput(s),
@@ -1,19 +1,16 @@
package server
import (
- "bytes"
"errors"
+ "fmt"
"os"
- "strings"
"testing"
"time"
- appCfg "github.com/charmbracelet/soft-serve/config"
- cm "github.com/charmbracelet/soft-serve/server/cmd"
"github.com/charmbracelet/soft-serve/server/config"
+ "github.com/charmbracelet/ssh"
bm "github.com/charmbracelet/wish/bubbletea"
"github.com/charmbracelet/wish/testsession"
- "github.com/gliderlabs/ssh"
"github.com/matryer/is"
"github.com/muesli/termenv"
gossh "golang.org/x/crypto/ssh"
@@ -21,53 +18,40 @@ import (
func TestSession(t *testing.T) {
is := is.New(t)
- t.Run("unauthorized repo access", func(t *testing.T) {
- var out bytes.Buffer
- s := setup(t)
- s.Stderr = &out
- defer s.Close()
- err := s.RequestPty("xterm", 80, 40, nil)
- is.NoErr(err)
- err = s.Run("config")
- // Session writes error and exits
- is.True(strings.Contains(out.String(), cm.ErrUnauthorized.Error()))
- var ee *gossh.ExitError
- is.True(errors.As(err, &ee) && ee.ExitStatus() == 1)
- })
t.Run("authorized repo access", func(t *testing.T) {
s := setup(t)
s.Stderr = os.Stderr
defer s.Close()
err := s.RequestPty("xterm", 80, 40, nil)
is.NoErr(err)
- in, err := s.StdinPipe()
- is.NoErr(err)
go func() {
- <-time.After(time.Second)
- // Send "q" to exit the config command
- in.Write([]byte("q"))
+ time.Sleep(1 * time.Second)
+ s.Signal(gossh.SIGTERM)
+ // FIXME: exit with code 0 instead of forcibly closing the session
+ s.Close()
}()
- err = s.Shell()
- is.NoErr(err)
+ err = s.Run("test")
+ var ee *gossh.ExitMissingError
+ is.True(errors.As(err, &ee))
})
}
func setup(tb testing.TB) *gossh.Session {
- is := is.New(tb)
tb.Helper()
- cfg.RepoPath = tb.TempDir()
- ac, err := appCfg.NewConfig(&config.Config{
- Port: 22226,
- KeyPath: tb.TempDir(),
- RepoPath: tb.TempDir(),
- InitialAdminKeys: []string{
- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMJlb/qf2B2kMNdBxfpCQqI2ctPcsOkdZGVh5zTRhKtH",
- },
+ is := is.New(tb)
+ dp := tb.TempDir()
+ is.NoErr(os.Setenv("SOFT_SERVE_DATA_PATH", dp))
+ is.NoErr(os.Setenv("SOFT_SERVE_GIT_LISTEN_ADDR", ":9418"))
+ is.NoErr(os.Setenv("SOFT_SERVE_SSH_LISTEN_ADDR", fmt.Sprintf(":%d", randomPort())))
+ tb.Cleanup(func() {
+ is.NoErr(os.Unsetenv("SOFT_SERVE_DATA_PATH"))
+ is.NoErr(os.Unsetenv("SOFT_SERVE_GIT_LISTEN_ADDR"))
+ is.NoErr(os.Unsetenv("SOFT_SERVE_SSH_LISTEN_ADDR"))
+ is.NoErr(os.RemoveAll(dp))
})
- ac.AnonAccess = "read-only"
- is.NoErr(err)
+ cfg := config.DefaultConfig()
return testsession.New(tb, &ssh.Server{
- Handler: bm.MiddlewareWithProgramHandler(SessionHandler(ac), termenv.ANSI256)(func(s ssh.Session) {
+ Handler: bm.MiddlewareWithProgramHandler(SessionHandler(cfg), termenv.ANSI256)(func(s ssh.Session) {
_, _, active := s.Pty()
tb.Logf("PTY active %v", active)
tb.Log(s.Command())
@@ -0,0 +1,157 @@
+package server
+
+import (
+ "errors"
+ "fmt"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/charmbracelet/log"
+ "github.com/charmbracelet/soft-serve/server/backend"
+ cm "github.com/charmbracelet/soft-serve/server/cmd"
+ "github.com/charmbracelet/soft-serve/server/config"
+ "github.com/charmbracelet/ssh"
+ "github.com/charmbracelet/wish"
+ bm "github.com/charmbracelet/wish/bubbletea"
+ lm "github.com/charmbracelet/wish/logging"
+ rm "github.com/charmbracelet/wish/recover"
+ "github.com/muesli/termenv"
+ gossh "golang.org/x/crypto/ssh"
+)
+
+// SSHServer is a SSH server that implements the git protocol.
+type SSHServer struct {
+ *ssh.Server
+ cfg *config.Config
+}
+
+// NewSSHServer returns a new SSHServer.
+func NewSSHServer(cfg *config.Config) (*SSHServer, error) {
+ var err error
+ s := &SSHServer{cfg: cfg}
+ logger := logger.StandardLog(log.StandardLogOptions{ForceLevel: log.DebugLevel})
+ mw := []wish.Middleware{
+ rm.MiddlewareWithLogger(
+ logger,
+ // BubbleTea middleware.
+ bm.MiddlewareWithProgramHandler(SessionHandler(cfg), termenv.ANSI256),
+ // Command middleware must come after the git middleware.
+ cm.Middleware(cfg),
+ // Git middleware.
+ s.Middleware(cfg),
+ lm.MiddlewareWithLogger(logger),
+ ),
+ }
+ s.Server, err = wish.NewServer(
+ ssh.PublicKeyAuth(s.PublicKeyHandler),
+ ssh.KeyboardInteractiveAuth(s.KeyboardInteractiveHandler),
+ wish.WithAddress(cfg.SSH.ListenAddr),
+ wish.WithHostKeyPath(cfg.SSH.KeyPath),
+ wish.WithMiddleware(mw...),
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ if cfg.SSH.MaxTimeout > 0 {
+ s.Server.MaxTimeout = time.Duration(cfg.SSH.MaxTimeout) * time.Second
+ }
+ if cfg.SSH.IdleTimeout > 0 {
+ s.Server.IdleTimeout = time.Duration(cfg.SSH.IdleTimeout) * time.Second
+ }
+
+ return s, nil
+}
+
+// PublicKeyAuthHandler handles public key authentication.
+func (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) bool {
+ al := s.cfg.Access.AccessLevel("", pk)
+ logger.Debug("publickey handler", "level", al)
+ return al > backend.NoAccess
+}
+
+// KeyboardInteractiveHandler handles keyboard interactive authentication.
+func (s *SSHServer) KeyboardInteractiveHandler(_ ssh.Context, _ gossh.KeyboardInteractiveChallenge) bool {
+ return true
+}
+
+// Middleware adds Git server functionality to the ssh.Server. Repos are stored
+// in the specified repo directory. The provided Hooks implementation will be
+// checked for access on a per repo basis for a ssh.Session public key.
+// Hooks.Push and Hooks.Fetch will be called on successful completion of
+// their commands.
+func (s *SSHServer) Middleware(cfg *config.Config) wish.Middleware {
+ return func(sh ssh.Handler) ssh.Handler {
+ return func(s ssh.Session) {
+ func() {
+ cmd := s.Command()
+ if len(cmd) >= 2 && strings.HasPrefix(cmd[0], "git") {
+ gc := cmd[0]
+ // repo should be in the form of "repo.git"
+ repo := sanitizeRepoName(cmd[1])
+ name := repo
+ if strings.Contains(repo, "/") {
+ log.Printf("invalid repo: %s", repo)
+ sshFatal(s, fmt.Errorf("%s: %s", ErrInvalidRepo, "user repos not supported"))
+ return
+ }
+ pk := s.PublicKey()
+ access := cfg.Access.AccessLevel(name, pk)
+ // git bare repositories should end in ".git"
+ // https://git-scm.com/docs/gitrepository-layout
+ repo = strings.TrimSuffix(repo, ".git") + ".git"
+ // FIXME: determine repositories path
+ repoDir := filepath.Join(cfg.DataPath, "repos", repo)
+ switch gc {
+ case ReceivePackBin:
+ if access < backend.ReadWriteAccess {
+ sshFatal(s, ErrNotAuthed)
+ return
+ }
+ if _, err := cfg.Backend.Repository(name); err != nil {
+ if _, err := cfg.Backend.CreateRepository(name, false); err != nil {
+ log.Printf("failed to create repo: %s", err)
+ sshFatal(s, err)
+ return
+ }
+ }
+ if err := ReceivePack(s, s, s.Stderr(), repoDir); err != nil {
+ sshFatal(s, ErrSystemMalfunction)
+ }
+ return
+ case UploadPackBin, UploadArchiveBin:
+ if access < backend.ReadOnlyAccess {
+ sshFatal(s, ErrNotAuthed)
+ return
+ }
+ gitPack := UploadPack
+ if gc == UploadArchiveBin {
+ gitPack = UploadArchive
+ }
+ err := gitPack(s, s, s.Stderr(), repoDir)
+ if errors.Is(err, ErrInvalidRepo) {
+ sshFatal(s, ErrInvalidRepo)
+ } else if err != nil {
+ sshFatal(s, ErrSystemMalfunction)
+ }
+ }
+ }
+ }()
+ sh(s)
+ }
+ }
+}
+
+// sshFatal prints to the session's STDOUT as a git response and exit 1.
+func sshFatal(s ssh.Session, v ...interface{}) {
+ WritePktline(s, v...)
+ s.Exit(1) // nolint: errcheck
+}
+
+func sanitizeRepoName(repo string) string {
+ repo = strings.TrimPrefix(repo, "/")
+ repo = filepath.Clean(repo)
+ repo = strings.TrimSuffix(repo, ".git")
+ return repo
+}
@@ -1,20 +1,56 @@
package common
import (
+ "context"
+
"github.com/aymanbagabas/go-osc52"
+ "github.com/charmbracelet/soft-serve/git"
+ "github.com/charmbracelet/soft-serve/server/config"
"github.com/charmbracelet/soft-serve/ui/keymap"
"github.com/charmbracelet/soft-serve/ui/styles"
+ "github.com/charmbracelet/ssh"
zone "github.com/lrstanley/bubblezone"
)
+type contextKey struct {
+ name string
+}
+
+// Keys to use for context.Context.
+var (
+ ConfigKey = &contextKey{"config"}
+ RepoKey = &contextKey{"repo"}
+)
+
// Common is a struct all components should embed.
type Common struct {
- Copy *osc52.Output
- Styles *styles.Styles
- KeyMap *keymap.KeyMap
- Width int
- Height int
- Zone *zone.Manager
+ ctx context.Context
+ Width, Height int
+ Styles *styles.Styles
+ KeyMap *keymap.KeyMap
+ Copy *osc52.Output
+ Zone *zone.Manager
+}
+
+// NewCommon returns a new Common struct.
+func NewCommon(ctx context.Context, copy *osc52.Output, width, height int) Common {
+ if ctx == nil {
+ ctx = context.TODO()
+ }
+ return Common{
+ ctx: ctx,
+ Width: width,
+ Height: height,
+ Copy: copy,
+ Styles: styles.DefaultStyles(),
+ KeyMap: keymap.DefaultKeyMap(),
+ Zone: zone.New(),
+ }
+}
+
+// SetValue sets a value in the context.
+func (c *Common) SetValue(key, value interface{}) {
+ c.ctx = context.WithValue(c.ctx, key, value)
}
// SetSize sets the width and height of the common struct.
@@ -22,3 +58,30 @@ func (c *Common) SetSize(width, height int) {
c.Width = width
c.Height = height
}
+
+// Config returns the server config.
+func (c *Common) Config() *config.Config {
+ v := c.ctx.Value(ConfigKey)
+ if cfg, ok := v.(*config.Config); ok {
+ return cfg
+ }
+ return nil
+}
+
+// Repo returns the repository.
+func (c *Common) Repo() *git.Repository {
+ v := c.ctx.Value(RepoKey)
+ if r, ok := v.(*git.Repository); ok {
+ return r
+ }
+ return nil
+}
+
+// PublicKey returns the public key.
+func (c *Common) PublicKey() ssh.PublicKey {
+ v := c.ctx.Value(ssh.ContextKeyPublicKey)
+ if p, ok := v.(ssh.PublicKey); ok {
+ return p
+ }
+ return nil
+}
@@ -1,6 +1,13 @@
package common
-import tea "github.com/charmbracelet/bubbletea"
+import (
+ "errors"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+// ErrMissingRepo indicates that the requested repository could not be found.
+var ErrMissingRepo = errors.New("missing repo")
// ErrorMsg is a Bubble Tea message that represents an error.
type ErrorMsg error
@@ -1,6 +1,10 @@
package common
-import "github.com/muesli/reflow/truncate"
+import (
+ "fmt"
+
+ "github.com/muesli/reflow/truncate"
+)
// TruncateString is a convenient wrapper around truncate.TruncateString.
func TruncateString(s string, max int) string {
@@ -9,3 +13,12 @@ func TruncateString(s string, max int) string {
}
return truncate.StringWithTail(s, uint(max), "β¦")
}
+
+// RepoURL returns the URL of the repository.
+func RepoURL(host string, port string, name string) string {
+ p := ""
+ if port != "22" {
+ p += ":" + port
+ }
+ return fmt.Sprintf("git clone ssh://%s/%s", host+p, name)
+}
@@ -47,7 +47,7 @@ func New(c common.Common, content, extension string) *Code {
content: content,
extension: extension,
Viewport: vp.New(c),
- NoContentStyle: c.Styles.CodeNoContent.Copy(),
+ NoContentStyle: c.Styles.NoContent.Copy(),
LineDigitStyle: lineDigitStyle,
LineBarStyle: lineBarStyle,
}
@@ -1,25 +0,0 @@
-package ui
-
-import (
- "github.com/charmbracelet/soft-serve/config"
- "github.com/charmbracelet/soft-serve/ui/git"
-)
-
-// source is a wrapper around config.RepoSource that implements git.GitRepoSource.
-type source struct {
- *config.RepoSource
-}
-
-// GetRepo implements git.GitRepoSource.
-func (s *source) GetRepo(name string) (git.GitRepo, error) {
- return s.RepoSource.GetRepo(name)
-}
-
-// AllRepos implements git.GitRepoSource.
-func (s *source) AllRepos() []git.GitRepo {
- rs := make([]git.GitRepo, 0)
- for _, r := range s.RepoSource.AllRepos() {
- rs = append(rs, r)
- }
- return rs
-}
@@ -1,42 +0,0 @@
-package git
-
-import (
- "errors"
- "fmt"
-
- "github.com/charmbracelet/soft-serve/git"
-)
-
-// ErrMissingRepo indicates that the requested repository could not be found.
-var ErrMissingRepo = errors.New("missing repo")
-
-// GitRepo is an interface for Git repositories.
-type GitRepo interface {
- Repo() string
- Name() string
- Description() string
- Readme() (string, string)
- HEAD() (*git.Reference, error)
- Commit(string) (*git.Commit, error)
- CommitsByPage(*git.Reference, int, int) (git.Commits, error)
- CountCommits(*git.Reference) (int64, error)
- Diff(*git.Commit) (*git.Diff, error)
- References() ([]*git.Reference, error)
- Tree(*git.Reference, string) (*git.Tree, error)
- IsPrivate() bool
-}
-
-// GitRepoSource is an interface for Git repository factory.
-type GitRepoSource interface {
- GetRepo(string) (GitRepo, error)
- AllRepos() []GitRepo
-}
-
-// RepoURL returns the URL of the repository.
-func RepoURL(host string, port int, name string) string {
- p := ""
- if port != 22 {
- p += fmt.Sprintf(":%d", port)
- }
- return fmt.Sprintf("git clone ssh://%s/%s", host+p, name)
-}
@@ -0,0 +1,45 @@
+package repo
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/charmbracelet/soft-serve/server/config"
+)
+
+func defaultEmptyRepoMsg(cfg *config.Config, repo string) string {
+ host := cfg.Backend.ServerHost()
+ if cfg.Backend.ServerPort() != "22" {
+ host = fmt.Sprintf("%s:%s", host, cfg.Backend.ServerPort())
+ }
+ repo = strings.TrimSuffix(repo, ".git")
+ return fmt.Sprintf(`# Quick Start
+
+Get started by cloning this repository, add your files, commit, and push.
+
+## Clone this repository.
+
+`+"```"+`sh
+git clone ssh://%[1]s/%[2]s.git
+`+"```"+`
+
+## Creating a new repository on the command line
+
+`+"```"+`sh
+touch README.md
+git init
+git add README.md
+git branch -M main
+git commit -m "first commit"
+git remote add origin ssh://%[1]s/%[2]s.git
+git push -u origin main
+`+"```"+`
+
+## Pushing an existing repository from the command line
+
+`+"```"+`sh
+git remote add origin ssh://%[1]s/%[2]s.git
+git push -u origin main
+`+"```"+`
+`, host, repo)
+}
@@ -3,16 +3,17 @@ package repo
import (
"errors"
"fmt"
+ "log"
"path/filepath"
"github.com/alecthomas/chroma/lexers"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
- ggit "github.com/charmbracelet/soft-serve/git"
+ "github.com/charmbracelet/soft-serve/git"
+ "github.com/charmbracelet/soft-serve/server/backend"
"github.com/charmbracelet/soft-serve/ui/common"
"github.com/charmbracelet/soft-serve/ui/components/code"
"github.com/charmbracelet/soft-serve/ui/components/selector"
- "github.com/charmbracelet/soft-serve/ui/git"
)
type filesView int
@@ -49,9 +50,9 @@ type FileContentMsg struct {
type Files struct {
common common.Common
selector *selector.Selector
- ref *ggit.Reference
+ ref *git.Reference
activeView filesView
- repo git.GitRepo
+ repo backend.Repository
code *code.Code
path string
currentItem *FileItem
@@ -200,8 +201,7 @@ func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds := make([]tea.Cmd, 0)
switch msg := msg.(type) {
case RepoMsg:
- f.repo = git.GitRepo(msg)
- cmds = append(cmds, f.Init())
+ f.repo = msg
case RefMsg:
f.ref = msg
cmds = append(cmds, f.Init())
@@ -265,6 +265,14 @@ func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
}
+ case EmptyRepoMsg:
+ f.ref = nil
+ f.path = ""
+ f.currentItem = nil
+ f.activeView = filesViewFiles
+ f.lastSelected = make([]int, 0)
+ f.selector.Select(0)
+ cmds = append(cmds, f.setItems([]selector.IdentifiableItem{}))
}
switch f.activeView {
case filesViewFiles:
@@ -320,14 +328,21 @@ func (f *Files) updateFilesCmd() tea.Msg {
files := make([]selector.IdentifiableItem, 0)
dirs := make([]selector.IdentifiableItem, 0)
if f.ref == nil {
+ log.Printf("ui: files: ref is nil")
return common.ErrorMsg(errNoRef)
}
- t, err := f.repo.Tree(f.ref, f.path)
+ r, err := f.repo.Repository()
+ if err != nil {
+ return common.ErrorMsg(err)
+ }
+ t, err := r.TreePath(f.ref, f.path)
if err != nil {
+ log.Printf("ui: files: error getting tree %v", err)
return common.ErrorMsg(err)
}
ents, err := t.Entries()
if err != nil {
+ log.Printf("ui: files: error listing files %v", err)
return common.ErrorMsg(err)
}
ents.Sort()
@@ -347,6 +362,7 @@ func (f *Files) selectTreeCmd() tea.Msg {
f.selector.Select(0)
return f.updateFilesCmd()
}
+ log.Printf("ui: files: current item is not a tree")
return common.ErrorMsg(errNoFileSelected)
}
@@ -355,25 +371,30 @@ func (f *Files) selectFileCmd() tea.Msg {
if i != nil && !i.entry.IsTree() {
fi := i.entry.File()
if i.Mode().IsDir() || f == nil {
+ log.Printf("ui: files: current item is not a file")
return common.ErrorMsg(errInvalidFile)
}
bin, err := fi.IsBinary()
if err != nil {
f.path = filepath.Dir(f.path)
+ log.Printf("ui: files: error checking if file is binary %v", err)
return common.ErrorMsg(err)
}
if bin {
f.path = filepath.Dir(f.path)
+ log.Printf("ui: files: file is binary")
return common.ErrorMsg(errBinaryFile)
}
c, err := fi.Bytes()
if err != nil {
f.path = filepath.Dir(f.path)
+ log.Printf("ui: files: error reading file %v", err)
return common.ErrorMsg(err)
}
f.lastSelected = append(f.lastSelected, f.selector.Index())
return FileContentMsg{string(c), i.entry.Name()}
}
+ log.Printf("ui: files: current item is not a file")
return common.ErrorMsg(errNoFileSelected)
}
@@ -389,3 +410,9 @@ func (f *Files) deselectItemCmd() tea.Msg {
f.selector.Select(index)
return msg
}
+
+func (f *Files) setItems(items []selector.IdentifiableItem) tea.Cmd {
+ return func() tea.Msg {
+ return FileItemsMsg(items)
+ }
+}
@@ -2,6 +2,7 @@ package repo
import (
"fmt"
+ "log"
"strings"
"time"
@@ -10,12 +11,12 @@ import (
tea "github.com/charmbracelet/bubbletea"
gansi "github.com/charmbracelet/glamour/ansi"
"github.com/charmbracelet/lipgloss"
- ggit "github.com/charmbracelet/soft-serve/git"
+ "github.com/charmbracelet/soft-serve/git"
+ "github.com/charmbracelet/soft-serve/server/backend"
"github.com/charmbracelet/soft-serve/ui/common"
"github.com/charmbracelet/soft-serve/ui/components/footer"
"github.com/charmbracelet/soft-serve/ui/components/selector"
"github.com/charmbracelet/soft-serve/ui/components/viewport"
- "github.com/charmbracelet/soft-serve/ui/git"
"github.com/muesli/reflow/wrap"
"github.com/muesli/termenv"
)
@@ -36,10 +37,10 @@ type LogCountMsg int64
type LogItemsMsg []selector.IdentifiableItem
// LogCommitMsg is a message that contains a git commit.
-type LogCommitMsg *ggit.Commit
+type LogCommitMsg *git.Commit
// LogDiffMsg is a message that contains a git diff.
-type LogDiffMsg *ggit.Diff
+type LogDiffMsg *git.Diff
// Log is a model that displays a list of commits and their diffs.
type Log struct {
@@ -47,13 +48,13 @@ type Log struct {
selector *selector.Selector
vp *viewport.Viewport
activeView logView
- repo git.GitRepo
- ref *ggit.Reference
+ repo backend.Repository
+ ref *git.Reference
count int64
nextPage int
- activeCommit *ggit.Commit
- selectedCommit *ggit.Commit
- currentDiff *ggit.Diff
+ activeCommit *git.Commit
+ selectedCommit *git.Commit
+ currentDiff *git.Diff
loadingTime time.Time
loading bool
spinner spinner.Model
@@ -77,9 +78,8 @@ func NewLog(common common.Common) *Log {
selector.KeyMap.NextPage = common.KeyMap.NextPage
selector.KeyMap.PrevPage = common.KeyMap.PrevPage
l.selector = selector
- s := spinner.New()
- s.Spinner = spinner.Dot
- s.Style = common.Styles.Spinner
+ s := spinner.New(spinner.WithSpinner(spinner.Dot),
+ spinner.WithStyle(common.Styles.Spinner))
l.spinner = s
return l
}
@@ -189,8 +189,7 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds := make([]tea.Cmd, 0)
switch msg := msg.(type) {
case RepoMsg:
- l.repo = git.GitRepo(msg)
- cmds = append(cmds, l.Init())
+ l.repo = msg
case RefMsg:
l.ref = msg
cmds = append(cmds, l.Init())
@@ -245,6 +244,7 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if l.activeView == logViewDiff {
l.activeView = logViewCommits
l.selectedCommit = nil
+ cmds = append(cmds, updateStatusBarCmd)
}
case selector.ActiveMsg:
switch sel := msg.IdentifiableItem.(type) {
@@ -299,6 +299,16 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
l.startLoading(),
)
}
+ case EmptyRepoMsg:
+ l.ref = nil
+ l.loading = false
+ l.activeView = logViewCommits
+ l.nextPage = 0
+ l.count = 0
+ l.activeCommit = nil
+ l.selectedCommit = nil
+ l.selector.Select(0)
+ cmds = append(cmds, l.setItems([]selector.IdentifiableItem{}))
}
if l.loading {
s, cmd := l.spinner.Update(msg)
@@ -326,7 +336,9 @@ func (l *Log) View() string {
msg += "s"
}
msg += "β¦"
- return msg
+ return l.common.Styles.SpinnerContainer.Copy().
+ Height(l.common.Height).
+ Render(msg)
}
switch l.activeView {
case logViewCommits:
@@ -374,10 +386,16 @@ func (l *Log) StatusBarInfo() string {
func (l *Log) countCommitsCmd() tea.Msg {
if l.ref == nil {
+ log.Printf("ui: log: ref is nil")
return common.ErrorMsg(errNoRef)
}
- count, err := l.repo.CountCommits(l.ref)
+ r, err := l.repo.Repository()
+ if err != nil {
+ return common.ErrorMsg(err)
+ }
+ count, err := r.CountCommits(l.ref)
if err != nil {
+ log.Printf("ui: error counting commits: %v", err)
return common.ErrorMsg(err)
}
return LogCountMsg(count)
@@ -394,15 +412,21 @@ func (l *Log) updateCommitsCmd() tea.Msg {
}
}
if l.ref == nil {
+ log.Printf("ui: log: ref is nil")
return common.ErrorMsg(errNoRef)
}
items := make([]selector.IdentifiableItem, count)
page := l.nextPage
limit := l.selector.PerPage()
skip := page * limit
+ r, err := l.repo.Repository()
+ if err != nil {
+ return common.ErrorMsg(err)
+ }
// CommitsByPage pages start at 1
- cc, err := l.repo.CommitsByPage(l.ref, page+1, limit)
+ cc, err := r.CommitsByPage(l.ref, page+1, limit)
if err != nil {
+ log.Printf("ui: error loading commits: %v", err)
return common.ErrorMsg(err)
}
for i, c := range cc {
@@ -415,15 +439,21 @@ func (l *Log) updateCommitsCmd() tea.Msg {
return LogItemsMsg(items)
}
-func (l *Log) selectCommitCmd(commit *ggit.Commit) tea.Cmd {
+func (l *Log) selectCommitCmd(commit *git.Commit) tea.Cmd {
return func() tea.Msg {
return LogCommitMsg(commit)
}
}
func (l *Log) loadDiffCmd() tea.Msg {
- diff, err := l.repo.Diff(l.selectedCommit)
+ r, err := l.repo.Repository()
if err != nil {
+ log.Printf("ui: error loading diff repository: %v", err)
+ return common.ErrorMsg(err)
+ }
+ diff, err := r.Diff(l.selectedCommit)
+ if err != nil {
+ log.Printf("ui: error loading diff: %v", err)
return common.ErrorMsg(err)
}
return LogDiffMsg(diff)
@@ -436,7 +466,7 @@ func renderCtx() gansi.RenderContext {
})
}
-func (l *Log) renderCommit(c *ggit.Commit) string {
+func (l *Log) renderCommit(c *git.Commit) string {
s := strings.Builder{}
// FIXME: lipgloss prints empty lines when CRLF is used
// sanitize commit message from CRLF
@@ -450,7 +480,7 @@ func (l *Log) renderCommit(c *ggit.Commit) string {
return wrap.String(s.String(), l.common.Width-2)
}
-func (l *Log) renderSummary(diff *ggit.Diff) string {
+func (l *Log) renderSummary(diff *git.Diff) string {
stats := strings.Split(diff.Stats().String(), "\n")
for i, line := range stats {
ch := strings.Split(line, "|")
@@ -464,7 +494,7 @@ func (l *Log) renderSummary(diff *ggit.Diff) string {
return wrap.String(strings.Join(stats, "\n"), l.common.Width-2)
}
-func (l *Log) renderDiff(diff *ggit.Diff) string {
+func (l *Log) renderDiff(diff *git.Diff) string {
var s strings.Builder
var pr strings.Builder
diffChroma := &gansi.CodeBlockElement{
@@ -479,3 +509,9 @@ func (l *Log) renderDiff(diff *ggit.Diff) string {
}
return wrap.String(s.String(), l.common.Width)
}
+
+func (l *Log) setItems(items []selector.IdentifiableItem) tea.Cmd {
+ return func() tea.Msg {
+ return LogItemsMsg(items)
+ }
+}
@@ -2,22 +2,27 @@ package repo
import (
"fmt"
+ "path/filepath"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/soft-serve/server/backend"
"github.com/charmbracelet/soft-serve/ui/common"
"github.com/charmbracelet/soft-serve/ui/components/code"
- "github.com/charmbracelet/soft-serve/ui/git"
)
-type ReadmeMsg struct{}
+// ReadmeMsg is a message sent when the readme is loaded.
+type ReadmeMsg struct {
+ Msg tea.Msg
+}
// Readme is the readme component page.
type Readme struct {
- common common.Common
- code *code.Code
- ref RefMsg
- repo git.GitRepo
+ common common.Common
+ code *code.Code
+ ref RefMsg
+ repo backend.Repository
+ readmePath string
}
// NewReadme creates a new readme model.
@@ -64,15 +69,7 @@ func (r *Readme) FullHelp() [][]key.Binding {
// Init implements tea.Model.
func (r *Readme) Init() tea.Cmd {
- if r.repo == nil {
- return common.ErrorCmd(git.ErrMissingRepo)
- }
- rm, rp := r.repo.Readme()
- r.code.GotoTop()
- return tea.Batch(
- r.code.SetContent(rm, rp),
- r.updateReadmeCmd,
- )
+ return r.updateReadmeCmd
}
// Update implements tea.Model.
@@ -80,11 +77,13 @@ func (r *Readme) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds := make([]tea.Cmd, 0)
switch msg := msg.(type) {
case RepoMsg:
- r.repo = git.GitRepo(msg)
- cmds = append(cmds, r.Init())
+ r.repo = msg
case RefMsg:
r.ref = msg
cmds = append(cmds, r.Init())
+ case EmptyRepoMsg:
+ r.code.SetContent(defaultEmptyRepoMsg(r.common.Config(),
+ r.repo.Name()), ".md")
}
c, cmd := r.code.Update(msg)
r.code = c.(*code.Code)
@@ -101,7 +100,11 @@ func (r *Readme) View() string {
// StatusBarValue implements statusbar.StatusBar.
func (r *Readme) StatusBarValue() string {
- return ""
+ dir := filepath.Dir(r.readmePath)
+ if dir == "." {
+ return ""
+ }
+ return dir
}
// StatusBarInfo implements statusbar.StatusBar.
@@ -110,5 +113,19 @@ func (r *Readme) StatusBarInfo() string {
}
func (r *Readme) updateReadmeCmd() tea.Msg {
- return ReadmeMsg{}
+ m := ReadmeMsg{}
+ if r.repo == nil {
+ return common.ErrorCmd(common.ErrMissingRepo)
+ }
+ rm, rp, err := backend.Readme(r.repo)
+ if err != nil {
+ return common.ErrorCmd(err)
+ }
+ r.readmePath = rp
+ r.code.GotoTop()
+ cmd := r.code.SetContent(rm, rp)
+ if cmd != nil {
+ m.Msg = cmd()
+ }
+ return m
}
@@ -3,22 +3,27 @@ package repo
import (
"errors"
"fmt"
+ "log"
"sort"
"strings"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/soft-serve/git"
ggit "github.com/charmbracelet/soft-serve/git"
+ "github.com/charmbracelet/soft-serve/server/backend"
"github.com/charmbracelet/soft-serve/ui/common"
"github.com/charmbracelet/soft-serve/ui/components/selector"
"github.com/charmbracelet/soft-serve/ui/components/tabs"
- "github.com/charmbracelet/soft-serve/ui/git"
)
var (
errNoRef = errors.New("no reference specified")
)
+// RefMsg is a message that contains a git.Reference.
+type RefMsg *ggit.Reference
+
// RefItemsMsg is a message that contains a list of RefItem.
type RefItemsMsg struct {
prefix string
@@ -29,9 +34,9 @@ type RefItemsMsg struct {
type Refs struct {
common common.Common
selector *selector.Selector
- repo git.GitRepo
- ref *ggit.Reference
- activeRef *ggit.Reference
+ repo backend.Repository
+ ref *git.Reference
+ activeRef *git.Reference
refPrefix string
}
@@ -104,8 +109,7 @@ func (r *Refs) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case RepoMsg:
r.selector.Select(0)
- r.repo = git.GitRepo(msg)
- cmds = append(cmds, r.Init())
+ r.repo = msg
case RefMsg:
r.ref = msg
cmds = append(cmds, r.Init())
@@ -136,6 +140,9 @@ func (r *Refs) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, r.common.KeyMap.SelectItem):
cmds = append(cmds, r.selector.SelectItem)
}
+ case EmptyRepoMsg:
+ r.ref = nil
+ cmds = append(cmds, r.setItems([]selector.IdentifiableItem{}))
}
m, cmd := r.selector.Update(msg)
r.selector = m.(*selector.Selector)
@@ -169,8 +176,13 @@ func (r *Refs) StatusBarInfo() string {
func (r *Refs) updateItemsCmd() tea.Msg {
its := make(RefItems, 0)
- refs, err := r.repo.References()
+ rr, err := r.repo.Repository()
+ if err != nil {
+ return common.ErrorMsg(err)
+ }
+ refs, err := rr.References()
if err != nil {
+ log.Printf("ui: error getting references: %v", err)
return common.ErrorMsg(err)
}
for _, ref := range refs {
@@ -189,8 +201,37 @@ func (r *Refs) updateItemsCmd() tea.Msg {
}
}
+func (r *Refs) setItems(items []selector.IdentifiableItem) tea.Cmd {
+ return func() tea.Msg {
+ return RefItemsMsg{
+ items: items,
+ prefix: r.refPrefix,
+ }
+ }
+}
+
func switchRefCmd(ref *ggit.Reference) tea.Cmd {
return func() tea.Msg {
return RefMsg(ref)
}
}
+
+// UpdateRefCmd gets the repository's HEAD reference and sends a RefMsg.
+func UpdateRefCmd(repo backend.Repository) tea.Cmd {
+ return func() tea.Msg {
+ r, err := repo.Repository()
+ if err != nil {
+ return common.ErrorMsg(err)
+ }
+ ref, err := r.HEAD()
+ if err != nil {
+ if bs, err := r.Branches(); err != nil && len(bs) == 0 {
+ return EmptyRepoMsg{}
+ }
+ log.Printf("ui: error getting HEAD reference: %v", err)
+ return common.ErrorMsg(err)
+ }
+ log.Printf("HEAD: %s", ref.Name())
+ return RefMsg(ref)
+ }
+}
@@ -9,20 +9,19 @@ import (
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
- "github.com/charmbracelet/soft-serve/config"
- ggit "github.com/charmbracelet/soft-serve/git"
+ "github.com/charmbracelet/soft-serve/git"
+ "github.com/charmbracelet/soft-serve/server/backend"
"github.com/charmbracelet/soft-serve/ui/common"
"github.com/charmbracelet/soft-serve/ui/components/footer"
"github.com/charmbracelet/soft-serve/ui/components/statusbar"
"github.com/charmbracelet/soft-serve/ui/components/tabs"
- "github.com/charmbracelet/soft-serve/ui/git"
)
type state int
const (
loadingState state = iota
- loadedState
+ readyState
)
type tab int
@@ -46,6 +45,9 @@ func (t tab) String() string {
}[t]
}
+// EmptyRepoMsg is a message to indicate that the repository is empty.
+type EmptyRepoMsg struct{}
+
// CopyURLMsg is a message to copy the URL of the current repository.
type CopyURLMsg struct{}
@@ -56,10 +58,7 @@ type ResetURLMsg struct{}
type UpdateStatusBarMsg struct{}
// RepoMsg is a message that contains a git.Repository.
-type RepoMsg git.GitRepo
-
-// RefMsg is a message that contains a git.Reference.
-type RefMsg *ggit.Reference
+type RepoMsg backend.Repository
// BackMsg is a message to go back to the previous view.
type BackMsg struct{}
@@ -67,18 +66,20 @@ type BackMsg struct{}
// Repo is a view for a git repository.
type Repo struct {
common common.Common
- cfg *config.Config
- selectedRepo git.GitRepo
+ selectedRepo backend.Repository
activeTab tab
tabs *tabs.Tabs
statusbar *statusbar.StatusBar
panes []common.Component
- ref *ggit.Reference
+ ref *git.Reference
copyURL time.Time
+ state state
+ spinner spinner.Model
+ panesReady [lastTab]bool
}
// New returns a new Repo.
-func New(cfg *config.Config, c common.Common) *Repo {
+func New(c common.Common) *Repo {
sb := statusbar.New(c)
ts := make([]string, lastTab)
// Tabs must match the order of tab constants above.
@@ -89,8 +90,8 @@ func New(cfg *config.Config, c common.Common) *Repo {
readme := NewReadme(c)
log := NewLog(c)
files := NewFiles(c)
- branches := NewRefs(c, ggit.RefsHeads)
- tags := NewRefs(c, ggit.RefsTags)
+ branches := NewRefs(c, git.RefsHeads)
+ tags := NewRefs(c, git.RefsTags)
// Make sure the order matches the order of tab constants above.
panes := []common.Component{
readme,
@@ -99,12 +100,15 @@ func New(cfg *config.Config, c common.Common) *Repo {
branches,
tags,
}
+ s := spinner.New(spinner.WithSpinner(spinner.Dot),
+ spinner.WithStyle(c.Styles.Spinner))
r := &Repo{
- cfg: cfg,
common: c,
tabs: tb,
statusbar: sb,
panes: panes,
+ state: loadingState,
+ spinner: s,
}
return r
}
@@ -162,16 +166,20 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds := make([]tea.Cmd, 0)
switch msg := msg.(type) {
case RepoMsg:
+ // Set the state to loading when we get a new repository.
+ r.state = loadingState
+ r.panesReady = [lastTab]bool{}
r.activeTab = 0
- r.selectedRepo = git.GitRepo(msg)
+ r.selectedRepo = msg
cmds = append(cmds,
r.tabs.Init(),
- r.updateRefCmd,
+ // This will set the selected repo in each pane's model.
r.updateModels(msg),
)
case RefMsg:
r.ref = msg
for _, p := range r.panes {
+ // Init will initiate each pane's model with its contents.
cmds = append(cmds, p.Init())
}
cmds = append(cmds,
@@ -200,7 +208,7 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
if r.selectedRepo != nil {
cmds = append(cmds, r.updateStatusBarCmd)
- urlID := fmt.Sprintf("%s-url", r.selectedRepo.Repo())
+ urlID := fmt.Sprintf("%s-url", r.selectedRepo.Name())
if msg, ok := msg.(tea.MouseMsg); ok && r.common.Zone.Get(urlID).InBounds(msg) {
cmds = append(cmds, r.copyURLCmd())
}
@@ -221,46 +229,46 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
case CopyURLMsg:
- r.common.Copy.Copy(
- git.RepoURL(r.cfg.Host, r.cfg.Port, r.selectedRepo.Repo()),
- )
+ if cfg := r.common.Config(); cfg != nil {
+ host := cfg.Backend.ServerHost()
+ port := cfg.Backend.ServerPort()
+ r.common.Copy.Copy(
+ common.RepoURL(host, port, r.selectedRepo.Name()),
+ )
+ }
case ResetURLMsg:
r.copyURL = time.Time{}
- case ReadmeMsg:
- case FileItemsMsg:
- f, cmd := r.panes[filesTab].Update(msg)
- r.panes[filesTab] = f.(*Files)
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
- // The Log bubble is the only bubble that uses a spinner, so this is fine
- // for now. We need to pass the TickMsg to the Log bubble when the Log is
- // loading but not the current selected tab so that the spinner works.
- case LogCountMsg, LogItemsMsg, spinner.TickMsg:
- l, cmd := r.panes[commitsTab].Update(msg)
- r.panes[commitsTab] = l.(*Log)
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
- case RefItemsMsg:
- switch msg.prefix {
- case ggit.RefsHeads:
- b, cmd := r.panes[branchesTab].Update(msg)
- r.panes[branchesTab] = b.(*Refs)
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
- case ggit.RefsTags:
- t, cmd := r.panes[tagsTab].Update(msg)
- r.panes[tagsTab] = t.(*Refs)
- if cmd != nil {
- cmds = append(cmds, cmd)
+ case ReadmeMsg, FileItemsMsg, LogCountMsg, LogItemsMsg, RefItemsMsg:
+ cmds = append(cmds, r.updateRepo(msg))
+ // We have two spinners, one is used to when loading the repository and the
+ // other is used when loading the log.
+ // Check if the spinner ID matches the spinner model.
+ case spinner.TickMsg:
+ switch msg.ID {
+ case r.spinner.ID():
+ if r.state == loadingState {
+ s, cmd := r.spinner.Update(msg)
+ r.spinner = s
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
}
+ default:
+ cmds = append(cmds, r.updateRepo(msg))
}
case UpdateStatusBarMsg:
cmds = append(cmds, r.updateStatusBarCmd)
case tea.WindowSizeMsg:
cmds = append(cmds, r.updateModels(msg))
+ case EmptyRepoMsg:
+ r.ref = nil
+ r.state = readyState
+ cmds = append(cmds,
+ r.updateModels(msg),
+ r.updateStatusBarCmd,
+ )
+ case common.ErrorMsg:
+ r.state = readyState
}
s, cmd := r.statusbar.Update(msg)
r.statusbar = s.(*statusbar.StatusBar)
@@ -289,15 +297,24 @@ func (r *Repo) View() string {
r.common.Styles.Tabs.GetVerticalFrameSize()
mainStyle := repoBodyStyle.
Height(r.common.Height - hm)
- main := r.common.Zone.Mark(
+ var main string
+ var statusbar string
+ switch r.state {
+ case loadingState:
+ main = fmt.Sprintf("%s loadingβ¦", r.spinner.View())
+ case readyState:
+ main = r.panes[r.activeTab].View()
+ statusbar = r.statusbar.View()
+ }
+ main = r.common.Zone.Mark(
"repo-main",
- mainStyle.Render(r.panes[r.activeTab].View()),
+ mainStyle.Render(main),
)
view := lipgloss.JoinVertical(lipgloss.Top,
r.headerView(),
r.tabs.View(),
main,
- r.statusbar.View(),
+ statusbar,
)
return s.Render(view)
}
@@ -306,7 +323,6 @@ func (r *Repo) headerView() string {
if r.selectedRepo == nil {
return ""
}
- cfg := r.cfg
truncate := lipgloss.NewStyle().MaxWidth(r.common.Width)
name := r.common.Styles.Repo.HeaderName.Render(r.selectedRepo.Name())
desc := r.selectedRepo.Description()
@@ -319,13 +335,16 @@ func (r *Repo) headerView() string {
urlStyle := r.common.Styles.URLStyle.Copy().
Width(r.common.Width - lipgloss.Width(desc) - 1).
Align(lipgloss.Right)
- url := git.RepoURL(cfg.Host, cfg.Port, r.selectedRepo.Repo())
+ var url string
+ if cfg := r.common.Config(); cfg != nil {
+ url = common.RepoURL(cfg.Backend.ServerHost(), cfg.Backend.ServerPort(), r.selectedRepo.Name())
+ }
if !r.copyURL.IsZero() && r.copyURL.Add(time.Second).After(time.Now()) {
url = "copied!"
}
url = common.TruncateString(url, r.common.Width-lipgloss.Width(desc)-1)
url = r.common.Zone.Mark(
- fmt.Sprintf("%s-url", r.selectedRepo.Repo()),
+ fmt.Sprintf("%s-url", r.selectedRepo.Name()),
urlStyle.Render(url),
)
style := r.common.Styles.Repo.Header.Copy().Width(r.common.Width)
@@ -346,27 +365,16 @@ func (r *Repo) updateStatusBarCmd() tea.Msg {
}
value := r.panes[r.activeTab].(statusbar.Model).StatusBarValue()
info := r.panes[r.activeTab].(statusbar.Model).StatusBarInfo()
- ref := ""
+ branch := "*"
if r.ref != nil {
- ref = r.ref.Name().Short()
+ branch += " " + r.ref.Name().Short()
}
return statusbar.StatusBarMsg{
- Key: r.selectedRepo.Repo(),
+ Key: r.selectedRepo.Name(),
Value: value,
Info: info,
- Branch: fmt.Sprintf("* %s", ref),
- }
-}
-
-func (r *Repo) updateRefCmd() tea.Msg {
- if r.selectedRepo == nil {
- return nil
- }
- head, err := r.selectedRepo.HEAD()
- if err != nil {
- return common.ErrorMsg(err)
+ Branch: branch,
}
- return RefMsg(head)
}
func (r *Repo) updateModels(msg tea.Msg) tea.Cmd {
@@ -381,6 +389,67 @@ func (r *Repo) updateModels(msg tea.Msg) tea.Cmd {
return tea.Batch(cmds...)
}
+func (r *Repo) updateRepo(msg tea.Msg) tea.Cmd {
+ cmds := make([]tea.Cmd, 0)
+ switch msg := msg.(type) {
+ case LogCountMsg, LogItemsMsg, spinner.TickMsg:
+ switch msg.(type) {
+ case LogItemsMsg:
+ r.panesReady[commitsTab] = true
+ }
+ l, cmd := r.panes[commitsTab].Update(msg)
+ r.panes[commitsTab] = l.(*Log)
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ case FileItemsMsg:
+ r.panesReady[filesTab] = true
+ f, cmd := r.panes[filesTab].Update(msg)
+ r.panes[filesTab] = f.(*Files)
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ case RefItemsMsg:
+ switch msg.prefix {
+ case git.RefsHeads:
+ r.panesReady[branchesTab] = true
+ b, cmd := r.panes[branchesTab].Update(msg)
+ r.panes[branchesTab] = b.(*Refs)
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ case git.RefsTags:
+ r.panesReady[tagsTab] = true
+ t, cmd := r.panes[tagsTab].Update(msg)
+ r.panes[tagsTab] = t.(*Refs)
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ }
+ case ReadmeMsg:
+ r.panesReady[readmeTab] = true
+ }
+ if r.isReady() {
+ r.state = readyState
+ }
+ return tea.Batch(cmds...)
+}
+
+func (r *Repo) isReady() bool {
+ ready := true
+ // We purposely ignore the log pane here because it has its own spinner.
+ for _, b := range []bool{
+ r.panesReady[filesTab], r.panesReady[branchesTab],
+ r.panesReady[tagsTab], r.panesReady[readmeTab],
+ } {
+ if !b {
+ ready = false
+ break
+ }
+ }
+ return ready
+}
+
func (r *Repo) copyURLCmd() tea.Cmd {
r.copyURL = time.Now()
return tea.Batch(
@@ -3,6 +3,7 @@ package selection
import (
"fmt"
"io"
+ "sort"
"strings"
"time"
@@ -10,26 +11,76 @@ import (
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/soft-serve/server/backend"
+ "github.com/charmbracelet/soft-serve/server/config"
"github.com/charmbracelet/soft-serve/ui/common"
- "github.com/charmbracelet/soft-serve/ui/git"
"github.com/dustin/go-humanize"
)
+var _ sort.Interface = Items{}
+
+// Items is a list of Item.
+type Items []Item
+
+// Len implements sort.Interface.
+func (it Items) Len() int {
+ return len(it)
+}
+
+// Less implements sort.Interface.
+func (it Items) Less(i int, j int) bool {
+ if it[i].lastUpdate == nil && it[j].lastUpdate != nil {
+ return false
+ }
+ if it[i].lastUpdate != nil && it[j].lastUpdate == nil {
+ return true
+ }
+ if it[i].lastUpdate == nil && it[j].lastUpdate == nil {
+ return it[i].repo.Name() < it[j].repo.Name()
+ }
+ return it[i].lastUpdate.After(*it[j].lastUpdate)
+}
+
+// Swap implements sort.Interface.
+func (it Items) Swap(i int, j int) {
+ it[i], it[j] = it[j], it[i]
+}
+
// Item represents a single item in the selector.
type Item struct {
- repo git.GitRepo
- lastUpdate time.Time
+ repo backend.Repository
+ lastUpdate *time.Time
cmd string
copied time.Time
}
+// New creates a new Item.
+func NewItem(repo backend.Repository, cfg *config.Config) (Item, error) {
+ r, err := repo.Repository()
+ if err != nil {
+ return Item{}, err
+ }
+ var lastUpdate *time.Time
+ lu, err := r.LatestCommitTime()
+ if err == nil {
+ lastUpdate = &lu
+ }
+ return Item{
+ repo: repo,
+ lastUpdate: lastUpdate,
+ cmd: common.RepoURL(cfg.Backend.ServerHost(), cfg.Backend.ServerPort(), repo.Name()),
+ }, nil
+}
+
// ID implements selector.IdentifiableItem.
func (i Item) ID() string {
- return i.repo.Repo()
+ return i.repo.Name()
}
// Title returns the item title. Implements list.DefaultItem.
-func (i Item) Title() string { return i.repo.Name() }
+func (i Item) Title() string {
+ return i.repo.Name()
+}
// Description returns the item description. Implements list.DefaultItem.
func (i Item) Description() string { return i.repo.Description() }
@@ -107,7 +158,10 @@ func (d ItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
if isSelected {
title += " "
}
- updatedStr := fmt.Sprintf(" Updated %s", humanize.Time(i.lastUpdate))
+ var updatedStr string
+ if i.lastUpdate != nil {
+ updatedStr = fmt.Sprintf(" Updated %s", humanize.Time(*i.lastUpdate))
+ }
if m.Width()-styles.Base.GetHorizontalFrameSize()-lipgloss.Width(updatedStr)-lipgloss.Width(title) <= 0 {
updatedStr = ""
}
@@ -2,20 +2,17 @@ package selection
import (
"fmt"
- "strings"
+ "log"
+ "sort"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
- "github.com/charmbracelet/soft-serve/config"
"github.com/charmbracelet/soft-serve/ui/common"
"github.com/charmbracelet/soft-serve/ui/components/code"
"github.com/charmbracelet/soft-serve/ui/components/selector"
"github.com/charmbracelet/soft-serve/ui/components/tabs"
- "github.com/charmbracelet/soft-serve/ui/git"
- wgit "github.com/charmbracelet/wish/git"
- "github.com/gliderlabs/ssh"
)
type pane int
@@ -35,8 +32,6 @@ func (p pane) String() string {
// Selection is the model for the selection screen/page.
type Selection struct {
- cfg *config.Config
- pk ssh.PublicKey
common common.Common
readme *code.Code
readmeHeight int
@@ -46,29 +41,27 @@ type Selection struct {
}
// New creates a new selection model.
-func New(cfg *config.Config, pk ssh.PublicKey, common common.Common) *Selection {
+func New(c common.Common) *Selection {
ts := make([]string, lastPane)
for i, b := range []pane{selectorPane, readmePane} {
ts[i] = b.String()
}
- t := tabs.New(common, ts)
+ t := tabs.New(c, ts)
t.TabSeparator = lipgloss.NewStyle()
- t.TabInactive = common.Styles.TopLevelNormalTab.Copy()
- t.TabActive = common.Styles.TopLevelActiveTab.Copy()
- t.TabDot = common.Styles.TopLevelActiveTabDot.Copy()
+ t.TabInactive = c.Styles.TopLevelNormalTab.Copy()
+ t.TabActive = c.Styles.TopLevelActiveTab.Copy()
+ t.TabDot = c.Styles.TopLevelActiveTabDot.Copy()
t.UseDot = true
sel := &Selection{
- cfg: cfg,
- pk: pk,
- common: common,
+ common: c,
activePane: selectorPane, // start with the selector focused
tabs: t,
}
- readme := code.New(common, "", "")
- readme.NoContentStyle = readme.NoContentStyle.SetString("No readme found.")
- selector := selector.New(common,
+ readme := code.New(c, "", "")
+ readme.NoContentStyle = c.Styles.NoContent.Copy().SetString("No readme found.")
+ selector := selector.New(c,
[]selector.IdentifiableItem{},
- ItemDelegate{&common, &sel.activePane})
+ ItemDelegate{&c, &sel.activePane})
selector.SetShowTitle(false)
selector.SetShowHelp(false)
selector.SetShowStatusBar(false)
@@ -184,59 +177,29 @@ func (s *Selection) FullHelp() [][]key.Binding {
// Init implements tea.Model.
func (s *Selection) Init() tea.Cmd {
var readmeCmd tea.Cmd
- items := make([]selector.IdentifiableItem, 0)
- cfg := s.cfg
- pk := s.pk
+ cfg := s.common.Config()
+ pk := s.common.PublicKey()
+ if cfg == nil || pk == nil {
+ return nil
+ }
+ repos, err := cfg.Backend.Repositories()
+ if err != nil {
+ return common.ErrorCmd(err)
+ }
+ sortedItems := make(Items, 0)
// Put configured repos first
- for _, r := range cfg.Repos {
- acc := cfg.AuthRepo(r.Repo, pk)
- if r.Private && acc < wgit.ReadOnlyAccess {
- continue
- }
- repo, err := cfg.Source.GetRepo(r.Repo)
+ for _, r := range repos {
+ item, err := NewItem(r, cfg)
if err != nil {
+ log.Printf("ui: failed to create item for %s: %v", r.Name(), err)
continue
}
- items = append(items, Item{
- repo: repo,
- cmd: git.RepoURL(cfg.Host, cfg.Port, r.Repo),
- })
+ sortedItems = append(sortedItems, item)
}
- for _, r := range cfg.Source.AllRepos() {
- if r.Repo() == "config" {
- rm, rp := r.Readme()
- s.readmeHeight = strings.Count(rm, "\n")
- readmeCmd = s.readme.SetContent(rm, rp)
- }
- acc := cfg.AuthRepo(r.Repo(), pk)
- if r.IsPrivate() && acc < wgit.ReadOnlyAccess {
- continue
- }
- exists := false
- lc, err := r.Commit("HEAD")
- if err != nil {
- return common.ErrorCmd(err)
- }
- lastUpdate := lc.Committer.When
- if lastUpdate.IsZero() {
- lastUpdate = lc.Author.When
- }
- for i, item := range items {
- item := item.(Item)
- if item.repo.Repo() == r.Repo() {
- exists = true
- item.lastUpdate = lastUpdate
- items[i] = item
- break
- }
- }
- if !exists {
- items = append(items, Item{
- repo: r,
- lastUpdate: lastUpdate,
- cmd: git.RepoURL(cfg.Host, cfg.Port, r.Name()),
- })
- }
+ sort.Sort(sortedItems)
+ items := make([]selector.IdentifiableItem, len(sortedItems))
+ for i, it := range sortedItems {
+ items[i] = it
}
return tea.Batch(
s.selector.Init(),
@@ -119,12 +119,14 @@ type Styles struct {
Selector lipgloss.Style
FileContent lipgloss.Style
Paginator lipgloss.Style
- NoItems lipgloss.Style
}
- Spinner lipgloss.Style
+ Spinner lipgloss.Style
+ SpinnerContainer lipgloss.Style
- CodeNoContent lipgloss.Style
+ NoContent lipgloss.Style
+
+ NoItems lipgloss.Style
StatusBar lipgloss.Style
StatusBarKey lipgloss.Style
@@ -402,19 +404,21 @@ func DefaultStyles() *Styles {
s.Tree.Paginator = s.Log.Paginator.Copy()
- s.Tree.NoItems = s.AboutNoReadme.Copy()
-
s.Spinner = lipgloss.NewStyle().
MarginTop(1).
MarginLeft(2).
Foreground(lipgloss.Color("205"))
- s.CodeNoContent = lipgloss.NewStyle().
+ s.SpinnerContainer = lipgloss.NewStyle()
+
+ s.NoContent = lipgloss.NewStyle().
SetString("No Content.").
- MarginTop(1).
+ MarginTop(2).
MarginLeft(2).
Foreground(lipgloss.Color("242"))
+ s.NoItems = s.AboutNoReadme.Copy()
+
s.StatusBar = lipgloss.NewStyle().
Height(1)
@@ -1,19 +1,20 @@
package ui
import (
+ "errors"
+ "log"
+
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
- "github.com/charmbracelet/soft-serve/config"
+ "github.com/charmbracelet/soft-serve/server/backend"
"github.com/charmbracelet/soft-serve/ui/common"
"github.com/charmbracelet/soft-serve/ui/components/footer"
"github.com/charmbracelet/soft-serve/ui/components/header"
"github.com/charmbracelet/soft-serve/ui/components/selector"
- "github.com/charmbracelet/soft-serve/ui/git"
"github.com/charmbracelet/soft-serve/ui/pages/repo"
"github.com/charmbracelet/soft-serve/ui/pages/selection"
- "github.com/gliderlabs/ssh"
)
type page int
@@ -26,16 +27,14 @@ const (
type sessionState int
const (
- startState sessionState = iota
+ loadingState sessionState = iota
errorState
- loadedState
+ readyState
)
// UI is the main UI model.
type UI struct {
- cfg *config.Config
- session ssh.Session
- rs git.GitRepoSource
+ serverName string
initialRepo string
common common.Common
pages []common.Component
@@ -48,17 +47,18 @@ type UI struct {
}
// New returns a new UI model.
-func New(cfg *config.Config, s ssh.Session, c common.Common, initialRepo string) *UI {
- src := &source{cfg.Source}
- h := header.New(c, cfg.Name)
+func New(c common.Common, initialRepo string) *UI {
+ var serverName string
+ if cfg := c.Config(); cfg != nil {
+ serverName = cfg.Backend.ServerName()
+ }
+ h := header.New(c, serverName)
ui := &UI{
- cfg: cfg,
- session: s,
- rs: src,
+ serverName: serverName,
common: c,
pages: make([]common.Component, 2), // selection & repo
activePage: selectionPage,
- state: startState,
+ state: loadingState,
header: h,
initialRepo: initialRepo,
showFooter: true,
@@ -92,7 +92,7 @@ func (ui *UI) ShortHelp() []key.Binding {
switch ui.state {
case errorState:
b = append(b, ui.common.KeyMap.Back)
- case loadedState:
+ case readyState:
b = append(b, ui.pages[ui.activePage].ShortHelp()...)
}
if !ui.IsFiltering() {
@@ -108,7 +108,7 @@ func (ui *UI) FullHelp() [][]key.Binding {
switch ui.state {
case errorState:
b = append(b, []key.Binding{ui.common.KeyMap.Back})
- case loadedState:
+ case readyState:
b = append(b, ui.pages[ui.activePage].FullHelp()...)
}
h := []key.Binding{
@@ -136,15 +136,8 @@ func (ui *UI) SetSize(width, height int) {
// Init implements tea.Model.
func (ui *UI) Init() tea.Cmd {
- ui.pages[selectionPage] = selection.New(
- ui.cfg,
- ui.session.PublicKey(),
- ui.common,
- )
- ui.pages[repoPage] = repo.New(
- ui.cfg,
- ui.common,
- )
+ ui.pages[selectionPage] = selection.New(ui.common)
+ ui.pages[repoPage] = repo.New(ui.common)
ui.SetSize(ui.common.Width, ui.common.Height)
cmds := make([]tea.Cmd, 0)
cmds = append(cmds,
@@ -154,7 +147,7 @@ func (ui *UI) Init() tea.Cmd {
if ui.initialRepo != "" {
cmds = append(cmds, ui.initialRepoCmd(ui.initialRepo))
}
- ui.state = loadedState
+ ui.state = readyState
ui.SetSize(ui.common.Width, ui.common.Height)
return tea.Batch(cmds...)
}
@@ -171,6 +164,7 @@ func (ui *UI) IsFiltering() bool {
// Update implements tea.Model.
func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ log.Printf("msg received: %T", msg)
cmds := make([]tea.Cmd, 0)
switch msg := msg.(type) {
case tea.WindowSizeMsg:
@@ -188,7 +182,7 @@ func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, ui.common.KeyMap.Back) && ui.error != nil:
ui.error = nil
- ui.state = loadedState
+ ui.state = readyState
// Always show the footer on error.
ui.showFooter = ui.footer.ShowAll()
case key.Matches(msg, ui.common.KeyMap.Help):
@@ -220,14 +214,15 @@ func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
ui.showFooter = !ui.showFooter
}
case repo.RepoMsg:
+ ui.common.SetValue(common.RepoKey, msg)
ui.activePage = repoPage
// Show the footer on repo page if show all is set.
ui.showFooter = ui.footer.ShowAll()
+ cmds = append(cmds, repo.UpdateRefCmd(msg))
case common.ErrorMsg:
ui.error = msg
ui.state = errorState
ui.showFooter = true
- return ui, nil
case selector.SelectMsg:
switch msg.IdentifiableItem.(type) {
case selection.Item:
@@ -246,7 +241,7 @@ func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if cmd != nil {
cmds = append(cmds, cmd)
}
- if ui.state == loadedState {
+ if ui.state != loadingState {
m, cmd := ui.pages[ui.activePage].Update(msg)
ui.pages[ui.activePage] = m.(common.Component)
if cmd != nil {
@@ -263,7 +258,7 @@ func (ui *UI) View() string {
var view string
wm, hm := ui.getMargins()
switch ui.state {
- case startState:
+ case loadingState:
view = "Loading..."
case errorState:
err := ui.common.Styles.ErrorTitle.Render("Bummer")
@@ -276,7 +271,7 @@ func (ui *UI) View() string {
hm -
ui.common.Styles.Error.GetVerticalFrameSize()).
Render(err)
- case loadedState:
+ case readyState:
view = ui.pages[ui.activePage].View()
default:
view = "Unknown state :/ this is a bug!"
@@ -292,24 +287,40 @@ func (ui *UI) View() string {
)
}
+func (ui *UI) openRepo(rn string) (backend.Repository, error) {
+ cfg := ui.common.Config()
+ if cfg == nil {
+ return nil, errors.New("config is nil")
+ }
+ repos, err := cfg.Backend.Repositories()
+ if err != nil {
+ log.Printf("ui: failed to list repos: %v", err)
+ return nil, err
+ }
+ for _, r := range repos {
+ if r.Name() == rn {
+ return r, nil
+ }
+ }
+ return nil, common.ErrMissingRepo
+}
+
func (ui *UI) setRepoCmd(rn string) tea.Cmd {
return func() tea.Msg {
- for _, r := range ui.rs.AllRepos() {
- if r.Repo() == rn {
- return repo.RepoMsg(r)
- }
+ r, err := ui.openRepo(rn)
+ if err != nil {
+ return common.ErrorMsg(err)
}
- return common.ErrorMsg(git.ErrMissingRepo)
+ return repo.RepoMsg(r)
}
}
func (ui *UI) initialRepoCmd(rn string) tea.Cmd {
return func() tea.Msg {
- for _, r := range ui.rs.AllRepos() {
- if r.Repo() == rn {
- return repo.RepoMsg(r)
- }
+ r, err := ui.openRepo(rn)
+ if err != nil {
+ return nil
}
- return nil
+ return repo.RepoMsg(r)
}
}