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