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}