c1cbb4093f1446cad596aba179ab70032d03a010
- Author
- Ayman Bagabas <ayman.bagabas@gmail.com>
- Committer
- Ayman Bagabas <ayman.bagabas@gmail.com>
- Date
Message
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 }