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}