c1cbb4093f1446cad596aba179ab70032d03a010

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

Message

feat(server): ssh cli api and middleware

Diff

This diff is truncated to protect this page.

  1diff --git a/go.mod b/go.mod
  2index 5f3309903a6dcb74d512f8b67aad64ce0238261e..28f483877d5e5cefe4c48df2d8313ba9e8a97950 100644
  3--- a/go.mod
  4+++ b/go.mod
  5@@ -20,6 +20,7 @@ require (
  6 
  7 require (
  8 	github.com/aymanbagabas/go-osc52 v1.2.2
  9+	github.com/charmbracelet/keygen v0.3.0
 10 	github.com/charmbracelet/log v0.2.1
 11 	github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103
 12 	github.com/gobwas/glob v0.2.3
 13@@ -38,7 +39,6 @@ require (
 14 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
 15 	github.com/aymerick/douceur v0.2.0 // indirect
 16 	github.com/caarlos0/sshmarshal v0.1.0 // indirect
 17-	github.com/charmbracelet/keygen v0.3.0 // indirect
 18 	github.com/containerd/console v1.0.3 // indirect
 19 	github.com/dlclark/regexp2 v1.4.0 // indirect
 20 	github.com/go-logfmt/logfmt v0.6.0 // indirect
 21diff --git a/server/cmd/branch.go b/server/cmd/branch.go
 22new file mode 100644
 23index 0000000000000000000000000000000000000000..0b79ecceea8e730d6a00a4f9147761e796ba7dc9
 24--- /dev/null
 25+++ b/server/cmd/branch.go
 26@@ -0,0 +1,179 @@
 27+package cmd
 28+
 29+import (
 30+	"fmt"
 31+	"strings"
 32+
 33+	"github.com/charmbracelet/soft-serve/git"
 34+	gitm "github.com/gogs/git-module"
 35+	"github.com/spf13/cobra"
 36+)
 37+
 38+func branchCommand() *cobra.Command {
 39+	cmd := &cobra.Command{
 40+		Use:   "branch",
 41+		Short: "Manage repository branches",
 42+	}
 43+
 44+	cmd.AddCommand(
 45+		branchListCommand(),
 46+		branchDefaultCommand(),
 47+		branchDeleteCommand(),
 48+	)
 49+
 50+	return cmd
 51+}
 52+
 53+func branchListCommand() *cobra.Command {
 54+	cmd := &cobra.Command{
 55+		Use:               "list REPOSITORY",
 56+		Short:             "List repository branches",
 57+		Args:              cobra.ExactArgs(1),
 58+		PersistentPreRunE: checkIfReadable,
 59+		RunE: func(cmd *cobra.Command, args []string) error {
 60+			cfg, _ := fromContext(cmd)
 61+			rn := strings.TrimSuffix(args[0], ".git")
 62+			rr, err := cfg.Backend.Repository(rn)
 63+			if err != nil {
 64+				return err
 65+			}
 66+
 67+			r, err := rr.Open()
 68+			if err != nil {
 69+				return err
 70+			}
 71+
 72+			branches, _ := r.Branches()
 73+			for _, b := range branches {
 74+				cmd.Println(b)
 75+			}
 76+
 77+			return nil
 78+		},
 79+	}
 80+
 81+	return cmd
 82+}
 83+
 84+func branchDefaultCommand() *cobra.Command {
 85+	cmd := &cobra.Command{
 86+		Use:   "default REPOSITORY [BRANCH]",
 87+		Short: "Set or get the default branch",
 88+		Args:  cobra.RangeArgs(1, 2),
 89+		RunE: func(cmd *cobra.Command, args []string) error {
 90+			cfg, _ := fromContext(cmd)
 91+			rn := strings.TrimSuffix(args[0], ".git")
 92+			switch len(args) {
 93+			case 1:
 94+				if err := checkIfReadable(cmd, args); err != nil {
 95+					return err
 96+				}
 97+				rr, err := cfg.Backend.Repository(rn)
 98+				if err != nil {
 99+					return err
100+				}
101+
102+				r, err := rr.Open()
103+				if err != nil {
104+					return err
105+				}
106+
107+				head, err := r.HEAD()
108+				if err != nil {
109+					return err
110+				}
111+
112+				cmd.Println(head.Name().Short())
113+			case 2:
114+				if err := checkIfCollab(cmd, args); err != nil {
115+					return err
116+				}
117+
118+				rr, err := cfg.Backend.Repository(rn)
119+				if err != nil {
120+					return err
121+				}
122+
123+				r, err := rr.Open()
124+				if err != nil {
125+					return err
126diff --git a/server/cmd/cmd.go b/server/cmd/cmd.go
127index 00cfc3f26a8515c01344517fb607cd40dfa32b83..60b0ab1cb2d8ec7c7b34d4629d98d90b28c5ce85 100644
128--- a/server/cmd/cmd.go
129+++ b/server/cmd/cmd.go
130@@ -1,10 +1,14 @@
131 package cmd
132 
133 import (
134+	"context"
135 	"fmt"
136+	"strings"
137 
138+	"github.com/charmbracelet/soft-serve/server/backend"
139 	"github.com/charmbracelet/soft-serve/server/config"
140 	"github.com/charmbracelet/ssh"
141+	"github.com/charmbracelet/wish"
142 	"github.com/spf13/cobra"
143 )
144 
145@@ -13,7 +17,7 @@ type ContextKey string
146 
147 // String returns the string representation of the ContextKey.
148 func (c ContextKey) String() string {
149-	return "soft-serve cli context key " + string(c)
150+	return string(c) + "ContextKey"
151 }
152 
153 var (
154@@ -30,45 +34,27 @@ var (
155 	ErrRepoNotFound = fmt.Errorf("Repository not found")
156 	// ErrFileNotFound is returned when the file is not found.
157 	ErrFileNotFound = fmt.Errorf("File not found")
158-
159-	usageTemplate = `Usage:{{if .Runnable}}{{if .HasParent }}
160-  {{.Parent.Use}} {{end}}{{.Use}}{{if .HasAvailableFlags }} [flags]{{end}}{{end}}{{if .HasAvailableSubCommands}}
161-  {{if .HasParent }}{{.Parent.Use}} {{end}}{{.Use}} [command]{{end}}{{if gt (len .Aliases) 0}}
162-
163-Aliases:
164-  {{.NameAndAliases}}{{end}}{{if .HasExample}}
165-
166-Examples:
167-{{.Example}}{{end}}{{if .HasAvailableSubCommands}}
168-
169-Available Commands:{{range .Commands}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
170-  {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
171-
172-Flags:
173-{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
174-
175-Global Flags:
176-{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
177-
178-Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
179-  {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
180-
181-Use "{{.UseLine}} [command] --help" for more information about a command.{{end}}
182-`
183 )
184 
185-// RootCommand is the root command for the server.
186-func RootCommand() *cobra.Command {
187+// rootCommand is the root command for the server.
188+func rootCommand() *cobra.Command {
189 	rootCmd := &cobra.Command{
190-		Use:                   "ssh [-p PORT] HOST",
191-		Long:                  "Soft Serve is a self-hostable Git server for the command line.",
192-		Args:                  cobra.MinimumNArgs(1),
193-		DisableFlagsInUseLine: true,
194+		Use:          "soft",
195+		Short:        "Soft Serve is a self-hostable Git server for the command line.",
196+		SilenceUsage: true,
197 	}
198-	rootCmd.SetUsageTemplate(usageTemplate)
199+	// TODO: use command usage template to include hostname and port
200 	rootCmd.CompletionOptions.DisableDefaultCmd = true
201 	rootCmd.AddCommand(
202-		RepoCommand(),
203+		branchCommand(),
204+		createCommand(),
205+		deleteCommand(),
206+		descriptionCommand(),
207+		listCommand(),
208+		privateCommand(),
209+		renameCommand(),
210+		showCommand(),
211+		tagCommand(),
212 	)
213 
214 	return rootCmd
215@@ -80,3 +66,70 @@ func fromContext(cmd *cobra.Command) (*config.Config, ssh.Session) {
216 	s := ctx.Value(SessionCtxKey).(ssh.Session)
217 	return cfg, s
218 }
219+
220+func checkIfReadable(cmd *cobra.Command, args []string) error {
221+	var repo string
222+	if len(args) > 0 {
223+		repo = args[0]
224+	}
225+	cfg, s := fromContext(cmd)
226+	rn := strings.TrimSuffix(repo, ".git")
227+	auth := cfg.Access.AccessLevel(rn, s.PublicKey())
228+	if auth < backend.ReadOnlyAccess {
229+		return ErrUnauthorized
230diff --git a/server/cmd/create.go b/server/cmd/create.go
231new file mode 100644
232index 0000000000000000000000000000000000000000..89c261d12bfa8ef3cb6b8bc652652a3158391586
233--- /dev/null
234+++ b/server/cmd/create.go
235@@ -0,0 +1,26 @@
236+package cmd
237+
238+import "github.com/spf13/cobra"
239+
240+// createCommand is the command for creating a new repository.
241+func createCommand() *cobra.Command {
242+	var private bool
243+	var description string
244+	cmd := &cobra.Command{
245+		Use:               "create REPOSITORY",
246+		Short:             "Create a new repository.",
247+		Args:              cobra.ExactArgs(1),
248+		PersistentPreRunE: checkIfAdmin,
249+		RunE: func(cmd *cobra.Command, args []string) error {
250+			cfg, _ := fromContext(cmd)
251+			name := args[0]
252+			if _, err := cfg.Backend.CreateRepository(name, private); err != nil {
253+				return err
254+			}
255+			return nil
256+		},
257+	}
258+	cmd.Flags().BoolVarP(&private, "private", "p", false, "make the repository private")
259+	cmd.Flags().StringVarP(&description, "description", "d", "", "set the repository description")
260+	return cmd
261+}
262diff --git a/server/cmd/delete.go b/server/cmd/delete.go
263new file mode 100644
264index 0000000000000000000000000000000000000000..8dd94a64552f34620d801fe04a303aa88e24505b
265--- /dev/null
266+++ b/server/cmd/delete.go
267@@ -0,0 +1,22 @@
268+package cmd
269+
270+import "github.com/spf13/cobra"
271+
272+func deleteCommand() *cobra.Command {
273+	cmd := &cobra.Command{
274+		Use:               "delete REPOSITORY",
275+		Aliases:           []string{"del", "remove", "rm"},
276+		Short:             "Delete a repository.",
277+		Args:              cobra.ExactArgs(1),
278+		PersistentPreRunE: checkIfAdmin,
279+		RunE: func(cmd *cobra.Command, args []string) error {
280+			cfg, _ := fromContext(cmd)
281+			name := args[0]
282+			if err := cfg.Backend.DeleteRepository(name); err != nil {
283+				return err
284+			}
285+			return nil
286+		},
287+	}
288+	return cmd
289+}
290diff --git a/server/cmd/description.go b/server/cmd/description.go
291new file mode 100644
292index 0000000000000000000000000000000000000000..2eb59c5ebafa4628063374547390d352c7a704ea
293--- /dev/null
294+++ b/server/cmd/description.go
295@@ -0,0 +1,40 @@
296+package cmd
297+
298+import (
299+	"strings"
300+
301+	"github.com/spf13/cobra"
302+)
303+
304+func descriptionCommand() *cobra.Command {
305+	cmd := &cobra.Command{
306+		Use:     "description REPOSITORY [DESCRIPTION]",
307+		Aliases: []string{"desc"},
308+		Short:   "Set or get the description for a repository.",
309+		Args:    cobra.MinimumNArgs(1),
310+		RunE: func(cmd *cobra.Command, args []string) error {
311+			cfg, _ := fromContext(cmd)
312+			rn := strings.TrimSuffix(args[0], ".git")
313+			switch len(args) {
314+			case 1:
315+				if err := checkIfReadable(cmd, args); err != nil {
316+					return err
317+				}
318+
319+				desc := cfg.Backend.Description(rn)
320+				cmd.Println(desc)
321+			default:
322+				if err := checkIfCollab(cmd, args); err != nil {
323+					return err
324+				}
325+				if err := cfg.Backend.SetDescription(rn, strings.Join(args[1:], " ")); err != nil {
326+					return err
327+				}
328+			}
329+
330+			return nil
331+		},
332+	}
333+
334+	return cmd
335+}
336diff --git a/server/cmd/list.go b/server/cmd/list.go
337new file mode 100644
338index 0000000000000000000000000000000000000000..0bec7fd198aeca000b5ed3a02c82d7e31b134f1e
339--- /dev/null
340+++ b/server/cmd/list.go
341@@ -0,0 +1,95 @@
342+package cmd
343+
344+import (
345+	"fmt"
346+	"path/filepath"
347+	"strings"
348+
349+	"github.com/charmbracelet/soft-serve/git"
350+	"github.com/charmbracelet/soft-serve/server/backend"
351+	"github.com/spf13/cobra"
352+)
353+
354+// listCommand returns a command that list file or directory at path.
355+func listCommand() *cobra.Command {
356+	listCmd := &cobra.Command{
357+		Use:               "list PATH",
358+		Aliases:           []string{"ls"},
359+		Short:             "List files at repository.",
360+		Args:              cobra.RangeArgs(0, 1),
361+		PersistentPreRunE: checkIfReadable,
362+		RunE: func(cmd *cobra.Command, args []string) error {
363+			cfg, s := fromContext(cmd)
364+			rn := ""
365+			path := ""
366+			ps := []string{}
367+			if len(args) > 0 {
368+				path = filepath.Clean(args[0])
369+				ps = strings.Split(path, "/")
370+				rn = strings.TrimSuffix(ps[0], ".git")
371+				auth := cfg.Access.AccessLevel(rn, s.PublicKey())
372+				if auth < backend.ReadOnlyAccess {
373+					return ErrUnauthorized
374+				}
375+			}
376+			if path == "" || path == "." || path == "/" {
377+				repos, err := cfg.Backend.Repositories()
378+				if err != nil {
379+					return err
380+				}
381+				for _, r := range repos {
382+					if cfg.Access.AccessLevel(r.Name(), s.PublicKey()) >= backend.ReadOnlyAccess {
383+						cmd.Println(r.Name())
384+					}
385+				}
386+				return nil
387+			}
388+			rr, err := cfg.Backend.Repository(rn)
389+			if err != nil {
390+				return err
391+			}
392+			r, err := rr.Open()
393+			if err != nil {
394+				return err
395+			}
396+			head, err := r.HEAD()
397+			if err != nil {
398+				if bs, err := r.Branches(); err != nil && len(bs) == 0 {
399+					return fmt.Errorf("repository is empty")
400+				}
401+				return err
402+			}
403+			tree, err := r.TreePath(head, "")
404+			if err != nil {
405+				return err
406+			}
407+			subpath := strings.Join(ps[1:], "/")
408+			ents := git.Entries{}
409+			te, err := tree.TreeEntry(subpath)
410+			if err == git.ErrRevisionNotExist {
411+				return ErrFileNotFound
412+			}
413+			if err != nil {
414+				return err
415+			}
416+			if te.Type() == "tree" {
417+				tree, err = tree.SubTree(subpath)
418+				if err != nil {
419+					return err
420+				}
421+				ents, err = tree.Entries()
422+				if err != nil {
423+					return err
424+				}
425+			} else {
426+				ents = append(ents, te)
427+			}
428+			ents.Sort()
429+			for _, ent := range ents {
430+				cmd.Printf("%s\t%d\t %s\n", ent.Mode(), ent.Size(), ent.Name())
431+			}
432+			return nil
433+		},
434+	}
435+	return listCmd
436+}
437diff --git a/server/cmd/middleware.go b/server/cmd/middleware.go
438deleted file mode 100644
439index 8635a66507a980fe3cb5b292fc682d082f5767dd..0000000000000000000000000000000000000000
440--- a/server/cmd/middleware.go
441+++ /dev/null
442@@ -1,47 +0,0 @@
443-package cmd
444-
445-import (
446-	"context"
447-	"fmt"
448-
449-	"github.com/charmbracelet/soft-serve/server/config"
450-	"github.com/charmbracelet/ssh"
451-	"github.com/charmbracelet/wish"
452-)
453-
454-// Middleware is the Soft Serve middleware that handles SSH commands.
455-func Middleware(cfg *config.Config) wish.Middleware {
456-	return func(sh ssh.Handler) ssh.Handler {
457-		return func(s ssh.Session) {
458-			func() {
459-				_, _, active := s.Pty()
460-				if active {
461-					return
462-				}
463-				ctx := context.WithValue(s.Context(), ConfigCtxKey, cfg)
464-				ctx = context.WithValue(ctx, SessionCtxKey, s)
465-
466-				use := "ssh"
467-				port := cfg.Backend.ServerPort()
468-				if port != "22" {
469-					use += fmt.Sprintf(" -p%s", port)
470-				}
471-				use += fmt.Sprintf(" %s", cfg.Backend.ServerHost())
472-				cmd := RootCommand()
473-				cmd.Use = use
474-				cmd.CompletionOptions.DisableDefaultCmd = true
475-				cmd.SetIn(s)
476-				cmd.SetOut(s)
477-				cmd.SetErr(s.Stderr())
478-				cmd.SetArgs(s.Command())
479-				err := cmd.ExecuteContext(ctx)
480-				if err != nil {
481-					_, _ = s.Write([]byte(err.Error()))
482-					_ = s.Exit(1)
483-					return
484-				}
485-			}()
486-			sh(s)
487-		}
488-	}
489-}
490diff --git a/server/cmd/private.go b/server/cmd/private.go
491new file mode 100644
492index 0000000000000000000000000000000000000000..593845dfb81b7787047eef73b6a1053021c2750b
493--- /dev/null
494+++ b/server/cmd/private.go
495@@ -0,0 +1,44 @@
496+package cmd
497+
498+import (
499+	"strconv"
500+	"strings"
501+
502+	"github.com/spf13/cobra"
503+)
504+
505+func privateCommand() *cobra.Command {
506+	cmd := &cobra.Command{
507+		Use:   "private REPOSITORY [true|false]",
508+		Short: "Set or get a repository private property.",
509+		Args:  cobra.RangeArgs(1, 2),
510+		RunE: func(cmd *cobra.Command, args []string) error {
511+			cfg, _ := fromContext(cmd)
512+			rn := strings.TrimSuffix(args[0], ".git")
513+
514+			switch len(args) {
515+			case 1:
516+				if err := checkIfReadable(cmd, args); err != nil {
517+					return err
518+				}
519+
520+				isPrivate := cfg.Backend.IsPrivate(rn)
521+				cmd.Println(isPrivate)
522+			case 2:
523+				isPrivate, err := strconv.ParseBool(args[1])
524+				if err != nil {
525+					return err
526+				}
527+				if err := checkIfCollab(cmd, args); err != nil {
528+					return err
529+				}
530+				if err := cfg.Backend.SetPrivate(rn, isPrivate); err != nil {
531+					return err
532+				}
533+			}
534+			return nil
535+		},
536+	}
537+
538+	return cmd
539+}
540diff --git a/server/cmd/rename.go b/server/cmd/rename.go
541new file mode 100644
542index 0000000000000000000000000000000000000000..a5b88d83ac7f654ab3cab13fb90571ca1fd39cbe
543--- /dev/null
544+++ b/server/cmd/rename.go
545@@ -0,0 +1,23 @@
546+package cmd
547+
548+import "github.com/spf13/cobra"
549+
550+func renameCommand() *cobra.Command {
551+	cmd := &cobra.Command{
552+		Use:               "rename REPOSITORY NEW_NAME",
553+		Short:             "Rename an existing repository.",
554+		Args:              cobra.ExactArgs(2),
555+		PersistentPreRunE: checkIfCollab,
556+		RunE: func(cmd *cobra.Command, args []string) error {
557+			cfg, _ := fromContext(cmd)
558+			oldName := args[0]
559+			newName := args[1]
560+			if err := cfg.Backend.RenameRepository(oldName, newName); err != nil {
561+				return err
562+			}
563+			return nil
564+		},
565+	}
566+
567+	return cmd
568+}
569diff --git a/server/cmd/repo.go b/server/cmd/repo.go
570deleted file mode 100644
571index c2324bee76cc0f0b919d19c499d377df90be3f43..0000000000000000000000000000000000000000
572--- a/server/cmd/repo.go
573+++ /dev/null
574@@ -1,408 +0,0 @@
575-package cmd
576-
577-import (
578-	"fmt"
579-	"path/filepath"
580-	"strconv"
581-	"strings"
582-
583-	"github.com/alecthomas/chroma/lexers"
584-	gansi "github.com/charmbracelet/glamour/ansi"
585-	"github.com/charmbracelet/lipgloss"
586-	"github.com/charmbracelet/soft-serve/git"
587-	"github.com/charmbracelet/soft-serve/server/backend"
588-	"github.com/charmbracelet/soft-serve/ui/common"
589-	"github.com/muesli/termenv"
590-	"github.com/spf13/cobra"
591-)
592-
593-// RepoCommand is the command for managing repositories.
594-func RepoCommand() *cobra.Command {
595-	cmd := &cobra.Command{
596-		Use:     "repo COMMAND",
597-		Aliases: []string{"repository", "repositories"},
598-		Short:   "Manage repositories.",
599-	}
600-	cmd.AddCommand(
601-		setCommand(),
602-		createCommand(),
603-		deleteCommand(),
604-		listCommand(),
605-		showCommand(),
606-	)
607-	return cmd
608-}
609-
610-func setCommand() *cobra.Command {
611-	cmd := &cobra.Command{
612-		Use:   "set",
613-		Short: "Set repository properties.",
614-	}
615-	cmd.AddCommand(
616-		setName(),
617-		setDescription(),
618-		setPrivate(),
619-		setDefaultBranch(),
620-	)
621-	return cmd
622-}
623-
624-// createCommand is the command for creating a new repository.
625-func createCommand() *cobra.Command {
626-	var private bool
627-	var description string
628-	var projectName string
629-	cmd := &cobra.Command{
630-		Use:   "create REPOSITORY",
631-		Short: "Create a new repository.",
632-		Args:  cobra.ExactArgs(1),
633-		PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
634-			cfg, s := fromContext(cmd)
635-			if !cfg.Backend.IsAdmin(s.PublicKey()) {
636-				return ErrUnauthorized
637-			}
638-			return nil
639-		},
640-		RunE: func(cmd *cobra.Command, args []string) error {
641-			cfg, _ := fromContext(cmd)
642-			name := args[0]
643-			if _, err := cfg.Backend.CreateRepository(name, private); err != nil {
644-				return err
645-			}
646-			return nil
647-		},
648-	}
649-	cmd.Flags().BoolVarP(&private, "private", "p", false, "make the repository private")
650-	cmd.Flags().StringVarP(&description, "description", "d", "", "set the repository description")
651-	cmd.Flags().StringVarP(&projectName, "project-name", "n", "", "set the project name")
652-	return cmd
653-}
654-
655-func deleteCommand() *cobra.Command {
656-	cmd := &cobra.Command{
657-		Use:               "delete REPOSITORY",
658-		Short:             "Delete a repository.",
659-		Args:              cobra.ExactArgs(1),
660-		PersistentPreRunE: checkIfAdmin,
661-		RunE: func(cmd *cobra.Command, args []string) error {
662-			cfg, _ := fromContext(cmd)
663-			name := args[0]
664-			if err := cfg.Backend.DeleteRepository(name); err != nil {
665-				return err
666-			}
667-			return nil
668-		},
669-	}
670-	return cmd
671-}
672-
673-func checkIfReadable(cmd *cobra.Command, args []string) error {
674diff --git a/server/cmd/show.go b/server/cmd/show.go
675new file mode 100644
676index 0000000000000000000000000000000000000000..cab515e4f78b2d2c7a8a0a922a2da2ae10302293
677--- /dev/null
678+++ b/server/cmd/show.go
679@@ -0,0 +1,128 @@
680+package cmd
681+
682+import (
683+	"fmt"
684+	"strings"
685+
686+	"github.com/alecthomas/chroma/lexers"
687+	gansi "github.com/charmbracelet/glamour/ansi"
688+	"github.com/charmbracelet/lipgloss"
689+	"github.com/charmbracelet/soft-serve/server/backend"
690+	"github.com/charmbracelet/soft-serve/ui/common"
691+	"github.com/muesli/termenv"
692+	"github.com/spf13/cobra"
693+)
694+
695+var (
696+	lineDigitStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("239"))
697+	lineBarStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("236"))
698+	dirnameStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("#00AAFF"))
699+	filenameStyle  = lipgloss.NewStyle()
700+	filemodeStyle  = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777"))
701+)
702+
703+// showCommand returns a command that prints the contents of a file.
704+func showCommand() *cobra.Command {
705+	var linenumber bool
706+	var color bool
707+
708+	showCmd := &cobra.Command{
709+		Use:               "show PATH",
710+		Aliases:           []string{"cat"},
711+		Short:             "Read the contents of file at path.",
712+		Args:              cobra.ExactArgs(1),
713+		PersistentPreRunE: checkIfReadable,
714+		RunE: func(cmd *cobra.Command, args []string) error {
715+			cfg, s := fromContext(cmd)
716+			ps := strings.Split(args[0], "/")
717+			rn := strings.TrimSuffix(ps[0], ".git")
718+			fp := strings.Join(ps[1:], "/")
719+			auth := cfg.Access.AccessLevel(rn, s.PublicKey())
720+			if auth < backend.ReadOnlyAccess {
721+				return ErrUnauthorized
722+			}
723+			var repo backend.Repository
724+			repoExists := false
725+			repos, err := cfg.Backend.Repositories()
726+			if err != nil {
727+				return err
728+			}
729+			for _, rp := range repos {
730+				if rp.Name() == rn {
731+					repoExists = true
732+					repo = rp
733+					break
734+				}
735+			}
736+			if !repoExists {
737+				return ErrRepoNotFound
738+			}
739+			c, _, err := backend.LatestFile(repo, fp)
740+			if err != nil {
741+				return err
742+			}
743+			if color {
744+				c, err = withFormatting(fp, c)
745+				if err != nil {
746+					return err
747+				}
748+			}
749+			if linenumber {
750+				c = withLineNumber(c, color)
751+			}
752+			cmd.Println(c)
753+			return nil
754+		},
755+	}
756+	showCmd.Flags().BoolVarP(&linenumber, "linenumber", "l", false, "Print line numbers")
757+	showCmd.Flags().BoolVarP(&color, "color", "c", false, "Colorize output")
758+
759+	return showCmd
760+}
761+
762+func withLineNumber(s string, color bool) string {
763+	lines := strings.Split(s, "\n")
764+	// NB: len() is not a particularly safe way to count string width (because
765+	// it's counting bytes instead of runes) but in this case it's okay
766+	// because we're only dealing with digits, which are one byte each.
767+	mll := len(fmt.Sprintf("%d", len(lines)))
768+	for i, l := range lines {
769+		digit := fmt.Sprintf("%*d", mll, i+1)
770+		bar := "│"
771+		if color {
772+			digit = lineDigitStyle.Render(digit)
773+			bar = lineBarStyle.Render(bar)
774+		}
775+		if i < len(lines)-1 || len(l) != 0 {
776+			// If the final line was a newline we'll get an empty string for
777+			// the final line, so drop the newline altogether.
778+			lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l)
779diff --git a/server/cmd/tag.go b/server/cmd/tag.go
780new file mode 100644
781index 0000000000000000000000000000000000000000..84e6a907dc30b01b462c7622b87a80b0646ad37b
782--- /dev/null
783+++ b/server/cmd/tag.go
784@@ -0,0 +1,80 @@
785+package cmd
786+
787+import (
788+	"strings"
789+
790+	"github.com/spf13/cobra"
791+)
792+
793+func tagCommand() *cobra.Command {
794+	cmd := &cobra.Command{
795+		Use:   "tag",
796+		Short: "Manage repository tags",
797+	}
798+
799+	cmd.AddCommand(
800+		tagListCommand(),
801+		tagDeleteCommand(),
802+	)
803+
804+	return cmd
805+}
806+
807+func tagListCommand() *cobra.Command {
808+	cmd := &cobra.Command{
809+		Use:               "list REPOSITORY",
810+		Aliases:           []string{"ls"},
811+		Short:             "List repository tags",
812+		Args:              cobra.ExactArgs(1),
813+		PersistentPreRunE: checkIfReadable,
814+		RunE: func(cmd *cobra.Command, args []string) error {
815+			cfg, _ := fromContext(cmd)
816+			rn := strings.TrimSuffix(args[0], ".git")
817+			rr, err := cfg.Backend.Repository(rn)
818+			if err != nil {
819+				return err
820+			}
821+
822+			r, err := rr.Open()
823+			if err != nil {
824+				return err
825+			}
826+
827+			tags, _ := r.Tags()
828+			for _, t := range tags {
829+				cmd.Println(t)
830+			}
831+
832+			return nil
833+		},
834+	}
835+
836+	return cmd
837+}
838+
839+func tagDeleteCommand() *cobra.Command {
840+	cmd := &cobra.Command{
841+		Use:               "delete REPOSITORY TAG",
842+		Aliases:           []string{"remove", "rm", "del"},
843+		Short:             "Delete a tag",
844+		Args:              cobra.ExactArgs(2),
845+		PersistentPreRunE: checkIfCollab,
846+		RunE: func(cmd *cobra.Command, args []string) error {
847+			cfg, _ := fromContext(cmd)
848+			rn := strings.TrimSuffix(args[0], ".git")
849+			rr, err := cfg.Backend.Repository(rn)
850+			if err != nil {
851+				return err
852+			}
853+
854+			r, err := rr.Open()
855+			if err != nil {
856+				return err
857+			}
858+
859+			return r.DeleteTag(args[1])
860+		},
861+	}
862+
863+	return cmd
864+}
865diff --git a/ui/pages/repo/files.go b/ui/pages/repo/files.go
866index 83f01d6ddf4063bba7a27f31b0c7fcff68ed17a9..d634bc9a1c9f4201a6284356c68d0c50fa5b67ee 100644
867--- a/ui/pages/repo/files.go
868+++ b/ui/pages/repo/files.go
869@@ -331,7 +331,7 @@ func (f *Files) updateFilesCmd() tea.Msg {
870 		log.Printf("ui: files: ref is nil")
871 		return common.ErrorMsg(errNoRef)
872 	}
873-	r, err := f.repo.Repository()
874+	r, err := f.repo.Open()
875 	if err != nil {
876 		return common.ErrorMsg(err)
877 	}
878diff --git a/ui/pages/repo/log.go b/ui/pages/repo/log.go
879index 6b5546590a516899ddd6d8f0753940b1b63edb24..30bac3f73f1f2285d1f2a66a86c1fa68da7ea10d 100644
880--- a/ui/pages/repo/log.go
881+++ b/ui/pages/repo/log.go
882@@ -388,7 +388,7 @@ func (l *Log) countCommitsCmd() tea.Msg {
883 		logger.Debugf("ui: log: ref is nil")
884 		return common.ErrorMsg(errNoRef)
885 	}
886-	r, err := l.repo.Repository()
887+	r, err := l.repo.Open()
888 	if err != nil {
889 		return common.ErrorMsg(err)
890 	}
891@@ -418,7 +418,7 @@ func (l *Log) updateCommitsCmd() tea.Msg {
892 	page := l.nextPage
893 	limit := l.selector.PerPage()
894 	skip := page * limit
895-	r, err := l.repo.Repository()
896+	r, err := l.repo.Open()
897 	if err != nil {
898 		return common.ErrorMsg(err)
899 	}
900@@ -445,7 +445,7 @@ func (l *Log) selectCommitCmd(commit *git.Commit) tea.Cmd {
901 }
902 
903 func (l *Log) loadDiffCmd() tea.Msg {
904-	r, err := l.repo.Repository()
905+	r, err := l.repo.Open()
906 	if err != nil {
907 		logger.Debugf("ui: error loading diff repository: %v", err)
908 		return common.ErrorMsg(err)
909diff --git a/ui/pages/repo/refs.go b/ui/pages/repo/refs.go
910index aa4e81f943f0b156bee372f000147e7bc811491f..d107264e0e4304766f682f272f1ee0c3decb0585 100644
911--- a/ui/pages/repo/refs.go
912+++ b/ui/pages/repo/refs.go
913@@ -175,7 +175,7 @@ func (r *Refs) StatusBarInfo() string {
914 
915 func (r *Refs) updateItemsCmd() tea.Msg {
916 	its := make(RefItems, 0)
917-	rr, err := r.repo.Repository()
918+	rr, err := r.repo.Open()
919 	if err != nil {
920 		return common.ErrorMsg(err)
921 	}
922@@ -218,7 +218,7 @@ func switchRefCmd(ref *ggit.Reference) tea.Cmd {
923 // UpdateRefCmd gets the repository's HEAD reference and sends a RefMsg.
924 func UpdateRefCmd(repo backend.Repository) tea.Cmd {
925 	return func() tea.Msg {
926-		r, err := repo.Repository()
927+		r, err := repo.Open()
928 		if err != nil {
929 			return common.ErrorMsg(err)
930 		}
931diff --git a/ui/pages/selection/item.go b/ui/pages/selection/item.go
932index 8e9abd69c12ca6e09ff255ad74aa08ac60c2cfd7..596ddcef2a287768cc19ccfbc17562e8086da33a 100644
933--- a/ui/pages/selection/item.go
934+++ b/ui/pages/selection/item.go
935@@ -56,7 +56,7 @@ type Item struct {
936 
937 // New creates a new Item.
938 func NewItem(repo backend.Repository, cfg *config.Config) (Item, error) {
939-	r, err := repo.Repository()
940+	r, err := repo.Open()
941 	if err != nil {
942 		return Item{}, err
943 	}