feat(server): ssh cli api and middleware

Ayman Bagabas created

Change summary

go.mod                     |   2 
server/cmd/branch.go       | 179 +++++++++++++++++
server/cmd/cmd.go          | 121 ++++++++---
server/cmd/create.go       |  26 ++
server/cmd/delete.go       |  22 ++
server/cmd/description.go  |  40 +++
server/cmd/list.go         |  95 +++++++++
server/cmd/middleware.go   |  47 ----
server/cmd/private.go      |  44 ++++
server/cmd/rename.go       |  23 ++
server/cmd/repo.go         | 408 ----------------------------------------
server/cmd/show.go         | 128 ++++++++++++
server/cmd/tag.go          |  80 +++++++
ui/pages/repo/files.go     |   2 
ui/pages/repo/log.go       |   6 
ui/pages/repo/refs.go      |   4 
ui/pages/selection/item.go |   2 
17 files changed, 732 insertions(+), 497 deletions(-)

Detailed changes

go.mod 🔗

@@ -20,6 +20,7 @@ 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
@@ -38,7 +39,6 @@ require (
 	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/charmbracelet/keygen v0.3.0 // indirect
 	github.com/containerd/console v1.0.3 // indirect
 	github.com/dlclark/regexp2 v1.4.0 // indirect
 	github.com/go-logfmt/logfmt v0.6.0 // indirect

server/cmd/branch.go 🔗

@@ -0,0 +1,179 @@
+package cmd
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/charmbracelet/soft-serve/git"
+	gitm "github.com/gogs/git-module"
+	"github.com/spf13/cobra"
+)
+
+func branchCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "branch",
+		Short: "Manage repository branches",
+	}
+
+	cmd.AddCommand(
+		branchListCommand(),
+		branchDefaultCommand(),
+		branchDeleteCommand(),
+	)
+
+	return cmd
+}
+
+func branchListCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:               "list REPOSITORY",
+		Short:             "List repository branches",
+		Args:              cobra.ExactArgs(1),
+		PersistentPreRunE: checkIfReadable,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, _ := fromContext(cmd)
+			rn := strings.TrimSuffix(args[0], ".git")
+			rr, err := cfg.Backend.Repository(rn)
+			if err != nil {
+				return err
+			}
+
+			r, err := rr.Open()
+			if err != nil {
+				return err
+			}
+
+			branches, _ := r.Branches()
+			for _, b := range branches {
+				cmd.Println(b)
+			}
+
+			return nil
+		},
+	}
+
+	return cmd
+}
+
+func branchDefaultCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "default REPOSITORY [BRANCH]",
+		Short: "Set or get the default branch",
+		Args:  cobra.RangeArgs(1, 2),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, _ := fromContext(cmd)
+			rn := strings.TrimSuffix(args[0], ".git")
+			switch len(args) {
+			case 1:
+				if err := checkIfReadable(cmd, args); err != nil {
+					return err
+				}
+				rr, err := cfg.Backend.Repository(rn)
+				if err != nil {
+					return err
+				}
+
+				r, err := rr.Open()
+				if err != nil {
+					return err
+				}
+
+				head, err := r.HEAD()
+				if err != nil {
+					return err
+				}
+
+				cmd.Println(head.Name().Short())
+			case 2:
+				if err := checkIfCollab(cmd, args); err != nil {
+					return err
+				}
+
+				rr, err := cfg.Backend.Repository(rn)
+				if err != nil {
+					return err
+				}
+
+				r, err := rr.Open()
+				if err != nil {
+					return err
+				}
+
+				branch := args[1]
+				branches, _ := r.Branches()
+				var exists bool
+				for _, b := range branches {
+					if branch == b {
+						exists = true
+						break
+					}
+				}
+
+				if !exists {
+					return git.ErrReferenceNotExist
+				}
+
+				if _, err := r.SymbolicRef("HEAD", gitm.RefsHeads+branch); err != nil {
+					return err
+				}
+			}
+
+			return nil
+		},
+	}
+
+	return cmd
+}
+
+func branchDeleteCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:               "delete REPOSITORY BRANCH",
+		Aliases:           []string{"remove", "rm", "del"},
+		Short:             "Delete a branch",
+		PersistentPreRunE: checkIfCollab,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, _ := fromContext(cmd)
+			rn := strings.TrimSuffix(args[0], ".git")
+			rr, err := cfg.Backend.Repository(rn)
+			if err != nil {
+				return err
+			}
+
+			r, err := rr.Open()
+			if err != nil {
+				return err
+			}
+
+			branch := args[1]
+			branches, _ := r.Branches()
+			var exists bool
+			for _, b := range branches {
+				if branch == b {
+					exists = true
+					break
+				}
+			}
+
+			if !exists {
+				return git.ErrReferenceNotExist
+			}
+
+			head, err := r.HEAD()
+			if err != nil {
+				return err
+			}
+
+			if head.Name().Short() == branch {
+				return fmt.Errorf("cannot delete the default branch")
+			}
+
+			if err := r.DeleteBranch(branch, gitm.DeleteBranchOptions{Force: true}); err != nil {
+				return err
+			}
+
+			return nil
+		},
+	}
+
+	return cmd
+}

server/cmd/cmd.go 🔗

@@ -1,10 +1,14 @@
 package cmd
 
 import (
+	"context"
 	"fmt"
+	"strings"
 
+	"github.com/charmbracelet/soft-serve/server/backend"
 	"github.com/charmbracelet/soft-serve/server/config"
 	"github.com/charmbracelet/ssh"
+	"github.com/charmbracelet/wish"
 	"github.com/spf13/cobra"
 )
 
@@ -13,7 +17,7 @@ type ContextKey string
 
 // String returns the string representation of the ContextKey.
 func (c ContextKey) String() string {
-	return "soft-serve cli context key " + string(c)
+	return string(c) + "ContextKey"
 }
 
 var (
@@ -30,45 +34,27 @@ var (
 	ErrRepoNotFound = fmt.Errorf("Repository not found")
 	// ErrFileNotFound is returned when the file is not found.
 	ErrFileNotFound = fmt.Errorf("File not found")
-
-	usageTemplate = `Usage:{{if .Runnable}}{{if .HasParent }}
-  {{.Parent.Use}} {{end}}{{.Use}}{{if .HasAvailableFlags }} [flags]{{end}}{{end}}{{if .HasAvailableSubCommands}}
-  {{if .HasParent }}{{.Parent.Use}} {{end}}{{.Use}} [command]{{end}}{{if gt (len .Aliases) 0}}
-
-Aliases:
-  {{.NameAndAliases}}{{end}}{{if .HasExample}}
-
-Examples:
-{{.Example}}{{end}}{{if .HasAvailableSubCommands}}
-
-Available Commands:{{range .Commands}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
-  {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
-
-Flags:
-{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
-
-Global Flags:
-{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
-
-Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
-  {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
-
-Use "{{.UseLine}} [command] --help" for more information about a command.{{end}}
-`
 )
 
-// RootCommand is the root command for the server.
-func RootCommand() *cobra.Command {
+// rootCommand is the root command for the server.
+func rootCommand() *cobra.Command {
 	rootCmd := &cobra.Command{
-		Use:                   "ssh [-p PORT] HOST",
-		Long:                  "Soft Serve is a self-hostable Git server for the command line.",
-		Args:                  cobra.MinimumNArgs(1),
-		DisableFlagsInUseLine: true,
+		Use:          "soft",
+		Short:        "Soft Serve is a self-hostable Git server for the command line.",
+		SilenceUsage: true,
 	}
-	rootCmd.SetUsageTemplate(usageTemplate)
+	// TODO: use command usage template to include hostname and port
 	rootCmd.CompletionOptions.DisableDefaultCmd = true
 	rootCmd.AddCommand(
-		RepoCommand(),
+		branchCommand(),
+		createCommand(),
+		deleteCommand(),
+		descriptionCommand(),
+		listCommand(),
+		privateCommand(),
+		renameCommand(),
+		showCommand(),
+		tagCommand(),
 	)
 
 	return rootCmd
@@ -80,3 +66,70 @@ func fromContext(cmd *cobra.Command) (*config.Config, ssh.Session) {
 	s := ctx.Value(SessionCtxKey).(ssh.Session)
 	return cfg, s
 }
+
+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
+}
+
+// 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() {
+				_, _, active := s.Pty()
+				if active {
+					return
+				}
+				ctx := context.WithValue(s.Context(), ConfigCtxKey, cfg)
+				ctx = context.WithValue(ctx, SessionCtxKey, s)
+
+				rootCmd := rootCommand()
+				rootCmd.SetArgs(s.Command())
+				if len(s.Command()) == 0 {
+					// otherwise it'll default to os.Args, which is not what we want.
+					rootCmd.SetArgs([]string{"--help"})
+				}
+				rootCmd.SetIn(s)
+				rootCmd.SetOut(s)
+				rootCmd.CompletionOptions.DisableDefaultCmd = true
+				rootCmd.SetErr(s.Stderr())
+				if err := rootCmd.ExecuteContext(ctx); err != nil {
+					_ = s.Exit(1)
+				}
+			}()
+			sh(s)
+		}
+	}
+}

server/cmd/create.go 🔗

@@ -0,0 +1,26 @@
+package cmd
+
+import "github.com/spf13/cobra"
+
+// createCommand is the command for creating a new repository.
+func createCommand() *cobra.Command {
+	var private bool
+	var description string
+	cmd := &cobra.Command{
+		Use:               "create REPOSITORY",
+		Short:             "Create a new 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.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")
+	return cmd
+}

server/cmd/delete.go 🔗

@@ -0,0 +1,22 @@
+package cmd
+
+import "github.com/spf13/cobra"
+
+func deleteCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:               "delete REPOSITORY",
+		Aliases:           []string{"del", "remove", "rm"},
+		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
+}

server/cmd/description.go 🔗

@@ -0,0 +1,40 @@
+package cmd
+
+import (
+	"strings"
+
+	"github.com/spf13/cobra"
+)
+
+func descriptionCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:     "description REPOSITORY [DESCRIPTION]",
+		Aliases: []string{"desc"},
+		Short:   "Set or get the description for a repository.",
+		Args:    cobra.MinimumNArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, _ := fromContext(cmd)
+			rn := strings.TrimSuffix(args[0], ".git")
+			switch len(args) {
+			case 1:
+				if err := checkIfReadable(cmd, args); err != nil {
+					return err
+				}
+
+				desc := cfg.Backend.Description(rn)
+				cmd.Println(desc)
+			default:
+				if err := checkIfCollab(cmd, args); err != nil {
+					return err
+				}
+				if err := cfg.Backend.SetDescription(rn, strings.Join(args[1:], " ")); err != nil {
+					return err
+				}
+			}
+
+			return nil
+		},
+	}
+
+	return cmd
+}

server/cmd/list.go 🔗

@@ -0,0 +1,95 @@
+package cmd
+
+import (
+	"fmt"
+	"path/filepath"
+	"strings"
+
+	"github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/server/backend"
+	"github.com/spf13/cobra"
+)
+
+// 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 files at repository.",
+		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 {
+						cmd.Println(r.Name())
+					}
+				}
+				return nil
+			}
+			rr, err := cfg.Backend.Repository(rn)
+			if err != nil {
+				return err
+			}
+			r, err := rr.Open()
+			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 {
+				cmd.Printf("%s\t%d\t %s\n", ent.Mode(), ent.Size(), ent.Name())
+			}
+			return nil
+		},
+	}
+	return listCmd
+}

server/cmd/middleware.go 🔗

@@ -1,47 +0,0 @@
-package cmd
-
-import (
-	"context"
-	"fmt"
-
-	"github.com/charmbracelet/soft-serve/server/config"
-	"github.com/charmbracelet/ssh"
-	"github.com/charmbracelet/wish"
-)
-
-// 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() {
-				_, _, active := s.Pty()
-				if active {
-					return
-				}
-				ctx := context.WithValue(s.Context(), ConfigCtxKey, cfg)
-				ctx = context.WithValue(ctx, SessionCtxKey, s)
-
-				use := "ssh"
-				port := cfg.Backend.ServerPort()
-				if port != "22" {
-					use += fmt.Sprintf(" -p%s", port)
-				}
-				use += fmt.Sprintf(" %s", cfg.Backend.ServerHost())
-				cmd := RootCommand()
-				cmd.Use = use
-				cmd.CompletionOptions.DisableDefaultCmd = true
-				cmd.SetIn(s)
-				cmd.SetOut(s)
-				cmd.SetErr(s.Stderr())
-				cmd.SetArgs(s.Command())
-				err := cmd.ExecuteContext(ctx)
-				if err != nil {
-					_, _ = s.Write([]byte(err.Error()))
-					_ = s.Exit(1)
-					return
-				}
-			}()
-			sh(s)
-		}
-	}
-}

server/cmd/private.go 🔗

@@ -0,0 +1,44 @@
+package cmd
+
+import (
+	"strconv"
+	"strings"
+
+	"github.com/spf13/cobra"
+)
+
+func privateCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "private REPOSITORY [true|false]",
+		Short: "Set or get a repository private property.",
+		Args:  cobra.RangeArgs(1, 2),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, _ := fromContext(cmd)
+			rn := strings.TrimSuffix(args[0], ".git")
+
+			switch len(args) {
+			case 1:
+				if err := checkIfReadable(cmd, args); err != nil {
+					return err
+				}
+
+				isPrivate := cfg.Backend.IsPrivate(rn)
+				cmd.Println(isPrivate)
+			case 2:
+				isPrivate, err := strconv.ParseBool(args[1])
+				if err != nil {
+					return err
+				}
+				if err := checkIfCollab(cmd, args); err != nil {
+					return err
+				}
+				if err := cfg.Backend.SetPrivate(rn, isPrivate); err != nil {
+					return err
+				}
+			}
+			return nil
+		},
+	}
+
+	return cmd
+}

server/cmd/rename.go 🔗

@@ -0,0 +1,23 @@
+package cmd
+
+import "github.com/spf13/cobra"
+
+func renameCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:               "rename REPOSITORY NEW_NAME",
+		Short:             "Rename an existing repository.",
+		Args:              cobra.ExactArgs(2),
+		PersistentPreRunE: checkIfCollab,
+		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
+}

server/cmd/repo.go 🔗

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

server/cmd/show.go 🔗

@@ -0,0 +1,128 @@
+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/server/backend"
+	"github.com/charmbracelet/soft-serve/ui/common"
+	"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"))
+)
+
+// 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:             "Read the contents of 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)
+			}
+			cmd.Println(c)
+			return nil
+		},
+	}
+	showCmd.Flags().BoolVarP(&linenumber, "linenumber", "l", false, "Print line numbers")
+	showCmd.Flags().BoolVarP(&color, "color", "c", false, "Colorize output")
+
+	return showCmd
+}
+
+func withLineNumber(s string, color bool) string {
+	lines := strings.Split(s, "\n")
+	// NB: len() is not a particularly safe way to count string width (because
+	// it's counting bytes instead of runes) but in this case it's okay
+	// because we're only dealing with digits, which are one byte each.
+	mll := len(fmt.Sprintf("%d", len(lines)))
+	for i, l := range lines {
+		digit := fmt.Sprintf("%*d", mll, i+1)
+		bar := "│"
+		if color {
+			digit = lineDigitStyle.Render(digit)
+			bar = lineBarStyle.Render(bar)
+		}
+		if i < len(lines)-1 || len(l) != 0 {
+			// If the final line was a newline we'll get an empty string for
+			// the final line, so drop the newline altogether.
+			lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l)
+		}
+	}
+	return strings.Join(lines, "\n")
+}
+
+func withFormatting(p, c string) (string, error) {
+	zero := uint(0)
+	lang := ""
+	lexer := lexers.Match(p)
+	if lexer != nil && lexer.Config() != nil {
+		lang = lexer.Config().Name
+	}
+	formatter := &gansi.CodeBlockElement{
+		Code:     c,
+		Language: lang,
+	}
+	r := strings.Builder{}
+	styles := common.StyleConfig()
+	styles.CodeBlock.Margin = &zero
+	rctx := gansi.NewRenderContext(gansi.Options{
+		Styles:       styles,
+		ColorProfile: termenv.TrueColor,
+	})
+	err := formatter.Render(&r, rctx)
+	if err != nil {
+		return "", err
+	}
+	return r.String(), nil
+}

server/cmd/tag.go 🔗

@@ -0,0 +1,80 @@
+package cmd
+
+import (
+	"strings"
+
+	"github.com/spf13/cobra"
+)
+
+func tagCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "tag",
+		Short: "Manage repository tags",
+	}
+
+	cmd.AddCommand(
+		tagListCommand(),
+		tagDeleteCommand(),
+	)
+
+	return cmd
+}
+
+func tagListCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:               "list REPOSITORY",
+		Aliases:           []string{"ls"},
+		Short:             "List repository tags",
+		Args:              cobra.ExactArgs(1),
+		PersistentPreRunE: checkIfReadable,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, _ := fromContext(cmd)
+			rn := strings.TrimSuffix(args[0], ".git")
+			rr, err := cfg.Backend.Repository(rn)
+			if err != nil {
+				return err
+			}
+
+			r, err := rr.Open()
+			if err != nil {
+				return err
+			}
+
+			tags, _ := r.Tags()
+			for _, t := range tags {
+				cmd.Println(t)
+			}
+
+			return nil
+		},
+	}
+
+	return cmd
+}
+
+func tagDeleteCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:               "delete REPOSITORY TAG",
+		Aliases:           []string{"remove", "rm", "del"},
+		Short:             "Delete a tag",
+		Args:              cobra.ExactArgs(2),
+		PersistentPreRunE: checkIfCollab,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg, _ := fromContext(cmd)
+			rn := strings.TrimSuffix(args[0], ".git")
+			rr, err := cfg.Backend.Repository(rn)
+			if err != nil {
+				return err
+			}
+
+			r, err := rr.Open()
+			if err != nil {
+				return err
+			}
+
+			return r.DeleteTag(args[1])
+		},
+	}
+
+	return cmd
+}

ui/pages/repo/files.go 🔗

@@ -331,7 +331,7 @@ func (f *Files) updateFilesCmd() tea.Msg {
 		log.Printf("ui: files: ref is nil")
 		return common.ErrorMsg(errNoRef)
 	}
-	r, err := f.repo.Repository()
+	r, err := f.repo.Open()
 	if err != nil {
 		return common.ErrorMsg(err)
 	}

ui/pages/repo/log.go 🔗

@@ -388,7 +388,7 @@ func (l *Log) countCommitsCmd() tea.Msg {
 		logger.Debugf("ui: log: ref is nil")
 		return common.ErrorMsg(errNoRef)
 	}
-	r, err := l.repo.Repository()
+	r, err := l.repo.Open()
 	if err != nil {
 		return common.ErrorMsg(err)
 	}
@@ -418,7 +418,7 @@ func (l *Log) updateCommitsCmd() tea.Msg {
 	page := l.nextPage
 	limit := l.selector.PerPage()
 	skip := page * limit
-	r, err := l.repo.Repository()
+	r, err := l.repo.Open()
 	if err != nil {
 		return common.ErrorMsg(err)
 	}
@@ -445,7 +445,7 @@ func (l *Log) selectCommitCmd(commit *git.Commit) tea.Cmd {
 }
 
 func (l *Log) loadDiffCmd() tea.Msg {
-	r, err := l.repo.Repository()
+	r, err := l.repo.Open()
 	if err != nil {
 		logger.Debugf("ui: error loading diff repository: %v", err)
 		return common.ErrorMsg(err)

ui/pages/repo/refs.go 🔗

@@ -175,7 +175,7 @@ func (r *Refs) StatusBarInfo() string {
 
 func (r *Refs) updateItemsCmd() tea.Msg {
 	its := make(RefItems, 0)
-	rr, err := r.repo.Repository()
+	rr, err := r.repo.Open()
 	if err != nil {
 		return common.ErrorMsg(err)
 	}
@@ -218,7 +218,7 @@ func switchRefCmd(ref *ggit.Reference) tea.Cmd {
 // 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()
+		r, err := repo.Open()
 		if err != nil {
 			return common.ErrorMsg(err)
 		}

ui/pages/selection/item.go 🔗

@@ -56,7 +56,7 @@ type Item struct {
 
 // New creates a new Item.
 func NewItem(repo backend.Repository, cfg *config.Config) (Item, error) {
-	r, err := repo.Repository()
+	r, err := repo.Open()
 	if err != nil {
 		return Item{}, err
 	}