1package cmd
  2
  3import (
  4	"fmt"
  5	"path/filepath"
  6	"strconv"
  7	"strings"
  8
  9	"github.com/alecthomas/chroma/lexers"
 10	gansi "github.com/charmbracelet/glamour/ansi"
 11	"github.com/charmbracelet/lipgloss"
 12	"github.com/charmbracelet/soft-serve/git"
 13	"github.com/charmbracelet/soft-serve/server/backend"
 14	"github.com/charmbracelet/soft-serve/ui/common"
 15	"github.com/muesli/termenv"
 16	"github.com/spf13/cobra"
 17)
 18
 19// RepoCommand is the command for managing repositories.
 20func RepoCommand() *cobra.Command {
 21	cmd := &cobra.Command{
 22		Use:     "repo COMMAND",
 23		Aliases: []string{"repository", "repositories"},
 24		Short:   "Manage repositories.",
 25	}
 26	cmd.AddCommand(
 27		setCommand(),
 28		createCommand(),
 29		deleteCommand(),
 30		listCommand(),
 31		showCommand(),
 32	)
 33	return cmd
 34}
 35
 36func setCommand() *cobra.Command {
 37	cmd := &cobra.Command{
 38		Use:   "set",
 39		Short: "Set repository properties.",
 40	}
 41	cmd.AddCommand(
 42		setName(),
 43		setDescription(),
 44		setPrivate(),
 45		setDefaultBranch(),
 46	)
 47	return cmd
 48}
 49
 50// createCommand is the command for creating a new repository.
 51func createCommand() *cobra.Command {
 52	var private bool
 53	var description string
 54	var projectName string
 55	cmd := &cobra.Command{
 56		Use:   "create REPOSITORY",
 57		Short: "Create a new repository.",
 58		Args:  cobra.ExactArgs(1),
 59		PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
 60			cfg, s := fromContext(cmd)
 61			if !cfg.Backend.IsAdmin(s.PublicKey()) {
 62				return ErrUnauthorized
 63			}
 64			return nil
 65		},
 66		RunE: func(cmd *cobra.Command, args []string) error {
 67			cfg, _ := fromContext(cmd)
 68			name := args[0]
 69			if _, err := cfg.Backend.CreateRepository(name, private); err != nil {
 70				return err
 71			}
 72			return nil
 73		},
 74	}
 75	cmd.Flags().BoolVarP(&private, "private", "p", false, "make the repository private")
 76	cmd.Flags().StringVarP(&description, "description", "d", "", "set the repository description")
 77	cmd.Flags().StringVarP(&projectName, "project-name", "n", "", "set the project name")
 78	return cmd
 79}
 80
 81func deleteCommand() *cobra.Command {
 82	cmd := &cobra.Command{
 83		Use:               "delete REPOSITORY",
 84		Short:             "Delete a repository.",
 85		Args:              cobra.ExactArgs(1),
 86		PersistentPreRunE: checkIfAdmin,
 87		RunE: func(cmd *cobra.Command, args []string) error {
 88			cfg, _ := fromContext(cmd)
 89			name := args[0]
 90			if err := cfg.Backend.DeleteRepository(name); err != nil {
 91				return err
 92			}
 93			return nil
 94		},
 95	}
 96	return cmd
 97}
 98
 99func checkIfReadable(cmd *cobra.Command, args []string) error {
100	var repo string
101	if len(args) > 0 {
102		repo = args[0]
103	}
104	cfg, s := fromContext(cmd)
105	rn := strings.TrimSuffix(repo, ".git")
106	auth := cfg.Access.AccessLevel(rn, s.PublicKey())
107	if auth < backend.ReadOnlyAccess {
108		return ErrUnauthorized
109	}
110	return nil
111}
112
113func checkIfAdmin(cmd *cobra.Command, args []string) error {
114	cfg, s := fromContext(cmd)
115	if !cfg.Backend.IsAdmin(s.PublicKey()) {
116		return ErrUnauthorized
117	}
118	return nil
119}
120
121func checkIfCollab(cmd *cobra.Command, args []string) error {
122	var repo string
123	if len(args) > 0 {
124		repo = args[0]
125	}
126	cfg, s := fromContext(cmd)
127	rn := strings.TrimSuffix(repo, ".git")
128	auth := cfg.Access.AccessLevel(rn, s.PublicKey())
129	if auth < backend.ReadWriteAccess {
130		return ErrUnauthorized
131	}
132	return nil
133}
134
135func setName() *cobra.Command {
136	cmd := &cobra.Command{
137		Use:               "name REPOSITORY NEW_NAME",
138		Short:             "Set the name for a repository.",
139		Args:              cobra.ExactArgs(2),
140		PersistentPreRunE: checkIfAdmin,
141		RunE: func(cmd *cobra.Command, args []string) error {
142			cfg, _ := fromContext(cmd)
143			oldName := args[0]
144			newName := args[1]
145			if err := cfg.Backend.RenameRepository(oldName, newName); err != nil {
146				return err
147			}
148			return nil
149		},
150	}
151	return cmd
152}
153
154func setDescription() *cobra.Command {
155	cmd := &cobra.Command{
156		Use:               "description REPOSITORY DESCRIPTION",
157		Short:             "Set the description for a repository.",
158		Args:              cobra.MinimumNArgs(2),
159		PersistentPreRunE: checkIfCollab,
160		RunE: func(cmd *cobra.Command, args []string) error {
161			cfg, _ := fromContext(cmd)
162			rn := strings.TrimSuffix(args[0], ".git")
163			if err := cfg.Backend.SetDescription(rn, strings.Join(args[1:], " ")); err != nil {
164				return err
165			}
166			return nil
167		},
168	}
169	return cmd
170}
171
172func setPrivate() *cobra.Command {
173	cmd := &cobra.Command{
174		Use:               "private REPOSITORY [true|false]",
175		Short:             "Set a repository to private.",
176		Args:              cobra.ExactArgs(2),
177		PersistentPreRunE: checkIfCollab,
178		RunE: func(cmd *cobra.Command, args []string) error {
179			cfg, _ := fromContext(cmd)
180			rn := strings.TrimSuffix(args[0], ".git")
181			isPrivate, err := strconv.ParseBool(args[1])
182			if err != nil {
183				return err
184			}
185			if err := cfg.Backend.SetPrivate(rn, isPrivate); err != nil {
186				return err
187			}
188			return nil
189		},
190	}
191	return cmd
192}
193
194func setDefaultBranch() *cobra.Command {
195	cmd := &cobra.Command{
196		Use:               "default-branch REPOSITORY BRANCH",
197		Short:             "Set the default branch for a repository.",
198		Args:              cobra.ExactArgs(2),
199		PersistentPreRunE: checkIfAdmin,
200		RunE: func(cmd *cobra.Command, args []string) error {
201			cfg, _ := fromContext(cmd)
202			rn := strings.TrimSuffix(args[0], ".git")
203			if err := cfg.Backend.SetDefaultBranch(rn, args[1]); err != nil {
204				return err
205			}
206			return nil
207		},
208	}
209	return cmd
210}
211
212// listCommand returns a command that list file or directory at path.
213func listCommand() *cobra.Command {
214	listCmd := &cobra.Command{
215		Use:               "list PATH",
216		Aliases:           []string{"ls"},
217		Short:             "List file or directory at path.",
218		Args:              cobra.RangeArgs(0, 1),
219		PersistentPreRunE: checkIfReadable,
220		RunE: func(cmd *cobra.Command, args []string) error {
221			cfg, s := fromContext(cmd)
222			rn := ""
223			path := ""
224			ps := []string{}
225			if len(args) > 0 {
226				path = filepath.Clean(args[0])
227				ps = strings.Split(path, "/")
228				rn = strings.TrimSuffix(ps[0], ".git")
229				auth := cfg.Access.AccessLevel(rn, s.PublicKey())
230				if auth < backend.ReadOnlyAccess {
231					return ErrUnauthorized
232				}
233			}
234			if path == "" || path == "." || path == "/" {
235				repos, err := cfg.Backend.Repositories()
236				if err != nil {
237					return err
238				}
239				for _, r := range repos {
240					if cfg.Access.AccessLevel(r.Name(), s.PublicKey()) >= backend.ReadOnlyAccess {
241						fmt.Fprintln(s, r.Name())
242					}
243				}
244				return nil
245			}
246			rr, err := cfg.Backend.Repository(rn)
247			if err != nil {
248				return err
249			}
250			r, err := rr.Repository()
251			if err != nil {
252				return err
253			}
254			head, err := r.HEAD()
255			if err != nil {
256				if bs, err := r.Branches(); err != nil && len(bs) == 0 {
257					return fmt.Errorf("repository is empty")
258				}
259				return err
260			}
261			tree, err := r.TreePath(head, "")
262			if err != nil {
263				return err
264			}
265			subpath := strings.Join(ps[1:], "/")
266			ents := git.Entries{}
267			te, err := tree.TreeEntry(subpath)
268			if err == git.ErrRevisionNotExist {
269				return ErrFileNotFound
270			}
271			if err != nil {
272				return err
273			}
274			if te.Type() == "tree" {
275				tree, err = tree.SubTree(subpath)
276				if err != nil {
277					return err
278				}
279				ents, err = tree.Entries()
280				if err != nil {
281					return err
282				}
283			} else {
284				ents = append(ents, te)
285			}
286			ents.Sort()
287			for _, ent := range ents {
288				fmt.Fprintf(s, "%s\t%d\t %s\n", ent.Mode(), ent.Size(), ent.Name())
289			}
290			return nil
291		},
292	}
293	return listCmd
294}
295
296var (
297	lineDigitStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("239"))
298	lineBarStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("236"))
299	dirnameStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("#00AAFF"))
300	filenameStyle  = lipgloss.NewStyle()
301	filemodeStyle  = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777"))
302)
303
304// showCommand returns a command that prints the contents of a file.
305func showCommand() *cobra.Command {
306	var linenumber bool
307	var color bool
308
309	showCmd := &cobra.Command{
310		Use:               "show PATH",
311		Aliases:           []string{"cat"},
312		Short:             "Outputs the contents of the file at path.",
313		Args:              cobra.ExactArgs(1),
314		PersistentPreRunE: checkIfReadable,
315		RunE: func(cmd *cobra.Command, args []string) error {
316			cfg, s := fromContext(cmd)
317			ps := strings.Split(args[0], "/")
318			rn := strings.TrimSuffix(ps[0], ".git")
319			fp := strings.Join(ps[1:], "/")
320			auth := cfg.Access.AccessLevel(rn, s.PublicKey())
321			if auth < backend.ReadOnlyAccess {
322				return ErrUnauthorized
323			}
324			var repo backend.Repository
325			repoExists := false
326			repos, err := cfg.Backend.Repositories()
327			if err != nil {
328				return err
329			}
330			for _, rp := range repos {
331				if rp.Name() == rn {
332					repoExists = true
333					repo = rp
334					break
335				}
336			}
337			if !repoExists {
338				return ErrRepoNotFound
339			}
340			c, _, err := backend.LatestFile(repo, fp)
341			if err != nil {
342				return err
343			}
344			if color {
345				c, err = withFormatting(fp, c)
346				if err != nil {
347					return err
348				}
349			}
350			if linenumber {
351				c = withLineNumber(c, color)
352			}
353			fmt.Fprint(s, c)
354			return nil
355		},
356	}
357	showCmd.Flags().BoolVarP(&linenumber, "linenumber", "l", false, "Print line numbers")
358	showCmd.Flags().BoolVarP(&color, "color", "c", false, "Colorize output")
359
360	return showCmd
361}
362
363func withLineNumber(s string, color bool) string {
364	lines := strings.Split(s, "\n")
365	// NB: len() is not a particularly safe way to count string width (because
366	// it's counting bytes instead of runes) but in this case it's okay
367	// because we're only dealing with digits, which are one byte each.
368	mll := len(fmt.Sprintf("%d", len(lines)))
369	for i, l := range lines {
370		digit := fmt.Sprintf("%*d", mll, i+1)
371		bar := "│"
372		if color {
373			digit = lineDigitStyle.Render(digit)
374			bar = lineBarStyle.Render(bar)
375		}
376		if i < len(lines)-1 || len(l) != 0 {
377			// If the final line was a newline we'll get an empty string for
378			// the final line, so drop the newline altogether.
379			lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l)
380		}
381	}
382	return strings.Join(lines, "\n")
383}
384
385func withFormatting(p, c string) (string, error) {
386	zero := uint(0)
387	lang := ""
388	lexer := lexers.Match(p)
389	if lexer != nil && lexer.Config() != nil {
390		lang = lexer.Config().Name
391	}
392	formatter := &gansi.CodeBlockElement{
393		Code:     c,
394		Language: lang,
395	}
396	r := strings.Builder{}
397	styles := common.StyleConfig()
398	styles.CodeBlock.Margin = &zero
399	rctx := gansi.NewRenderContext(gansi.Options{
400		Styles:       styles,
401		ColorProfile: termenv.TrueColor,
402	})
403	err := formatter.Render(&r, rctx)
404	if err != nil {
405		return "", err
406	}
407	return r.String(), nil
408}