diff --git a/go.mod b/go.mod index 5f3309903..28f483877 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( require ( github.com/aymanbagabas/go-osc52 v1.2.2 + github.com/charmbracelet/keygen v0.3.0 github.com/charmbracelet/log v0.2.1 github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103 github.com/gobwas/glob v0.2.3 @@ -38,7 +39,6 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/caarlos0/sshmarshal v0.1.0 // indirect - github.com/charmbracelet/keygen v0.3.0 // indirect github.com/containerd/console v1.0.3 // indirect github.com/dlclark/regexp2 v1.4.0 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect diff --git a/server/cmd/branch.go b/server/cmd/branch.go new file mode 100644 index 000000000..0b79eccee --- /dev/null +++ b/server/cmd/branch.go @@ -0,0 +1,179 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/soft-serve/git" + gitm "github.com/gogs/git-module" + "github.com/spf13/cobra" +) + +func branchCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "branch", + Short: "Manage repository branches", + } + + cmd.AddCommand( + branchListCommand(), + branchDefaultCommand(), + branchDeleteCommand(), + ) + + return cmd +} + +func branchListCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "list REPOSITORY", + Short: "List repository branches", + Args: cobra.ExactArgs(1), + PersistentPreRunE: checkIfReadable, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, _ := fromContext(cmd) + rn := strings.TrimSuffix(args[0], ".git") + rr, err := cfg.Backend.Repository(rn) + if err != nil { + return err + } + + r, err := rr.Open() + if err != nil { + return err + } + + branches, _ := r.Branches() + for _, b := range branches { + cmd.Println(b) + } + + return nil + }, + } + + return cmd +} + +func branchDefaultCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "default REPOSITORY [BRANCH]", + Short: "Set or get the default branch", + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, _ := fromContext(cmd) + rn := strings.TrimSuffix(args[0], ".git") + switch len(args) { + case 1: + if err := checkIfReadable(cmd, args); err != nil { + return err + } + rr, err := cfg.Backend.Repository(rn) + if err != nil { + return err + } + + r, err := rr.Open() + if err != nil { + return err + } + + head, err := r.HEAD() + if err != nil { + return err + } + + cmd.Println(head.Name().Short()) + case 2: + if err := checkIfCollab(cmd, args); err != nil { + return err + } + + rr, err := cfg.Backend.Repository(rn) + if err != nil { + return err + } + + r, err := rr.Open() + if err != nil { + return err + } + + branch := args[1] + branches, _ := r.Branches() + var exists bool + for _, b := range branches { + if branch == b { + exists = true + break + } + } + + if !exists { + return git.ErrReferenceNotExist + } + + if _, err := r.SymbolicRef("HEAD", gitm.RefsHeads+branch); err != nil { + return err + } + } + + return nil + }, + } + + return cmd +} + +func branchDeleteCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete REPOSITORY BRANCH", + Aliases: []string{"remove", "rm", "del"}, + Short: "Delete a branch", + PersistentPreRunE: checkIfCollab, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, _ := fromContext(cmd) + rn := strings.TrimSuffix(args[0], ".git") + rr, err := cfg.Backend.Repository(rn) + if err != nil { + return err + } + + r, err := rr.Open() + if err != nil { + return err + } + + branch := args[1] + branches, _ := r.Branches() + var exists bool + for _, b := range branches { + if branch == b { + exists = true + break + } + } + + if !exists { + return git.ErrReferenceNotExist + } + + head, err := r.HEAD() + if err != nil { + return err + } + + if head.Name().Short() == branch { + return fmt.Errorf("cannot delete the default branch") + } + + if err := r.DeleteBranch(branch, gitm.DeleteBranchOptions{Force: true}); err != nil { + return err + } + + return nil + }, + } + + return cmd +} diff --git a/server/cmd/cmd.go b/server/cmd/cmd.go index 00cfc3f26..60b0ab1cb 100644 --- a/server/cmd/cmd.go +++ b/server/cmd/cmd.go @@ -1,10 +1,14 @@ package cmd import ( + "context" "fmt" + "strings" + "github.com/charmbracelet/soft-serve/server/backend" "github.com/charmbracelet/soft-serve/server/config" "github.com/charmbracelet/ssh" + "github.com/charmbracelet/wish" "github.com/spf13/cobra" ) @@ -13,7 +17,7 @@ type ContextKey string // String returns the string representation of the ContextKey. func (c ContextKey) String() string { - return "soft-serve cli context key " + string(c) + return string(c) + "ContextKey" } var ( @@ -30,45 +34,27 @@ var ( ErrRepoNotFound = fmt.Errorf("Repository not found") // ErrFileNotFound is returned when the file is not found. ErrFileNotFound = fmt.Errorf("File not found") - - usageTemplate = `Usage:{{if .Runnable}}{{if .HasParent }} - {{.Parent.Use}} {{end}}{{.Use}}{{if .HasAvailableFlags }} [flags]{{end}}{{end}}{{if .HasAvailableSubCommands}} - {{if .HasParent }}{{.Parent.Use}} {{end}}{{.Use}} [command]{{end}}{{if gt (len .Aliases) 0}} - -Aliases: - {{.NameAndAliases}}{{end}}{{if .HasExample}} - -Examples: -{{.Example}}{{end}}{{if .HasAvailableSubCommands}} - -Available Commands:{{range .Commands}}{{if (or .IsAvailableCommand (eq .Name "help"))}} - {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} - -Flags: -{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} - -Global Flags: -{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}} - -Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} - {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}} - -Use "{{.UseLine}} [command] --help" for more information about a command.{{end}} -` ) -// RootCommand is the root command for the server. -func RootCommand() *cobra.Command { +// rootCommand is the root command for the server. +func rootCommand() *cobra.Command { rootCmd := &cobra.Command{ - Use: "ssh [-p PORT] HOST", - Long: "Soft Serve is a self-hostable Git server for the command line.", - Args: cobra.MinimumNArgs(1), - DisableFlagsInUseLine: true, + Use: "soft", + Short: "Soft Serve is a self-hostable Git server for the command line.", + SilenceUsage: true, } - rootCmd.SetUsageTemplate(usageTemplate) + // TODO: use command usage template to include hostname and port rootCmd.CompletionOptions.DisableDefaultCmd = true rootCmd.AddCommand( - RepoCommand(), + branchCommand(), + createCommand(), + deleteCommand(), + descriptionCommand(), + listCommand(), + privateCommand(), + renameCommand(), + showCommand(), + tagCommand(), ) return rootCmd @@ -80,3 +66,70 @@ func fromContext(cmd *cobra.Command) (*config.Config, ssh.Session) { s := ctx.Value(SessionCtxKey).(ssh.Session) return cfg, s } + +func checkIfReadable(cmd *cobra.Command, args []string) error { + var repo string + if len(args) > 0 { + repo = args[0] + } + cfg, s := fromContext(cmd) + rn := strings.TrimSuffix(repo, ".git") + auth := cfg.Access.AccessLevel(rn, s.PublicKey()) + if auth < backend.ReadOnlyAccess { + return ErrUnauthorized + } + return nil +} + +func checkIfAdmin(cmd *cobra.Command, args []string) error { + cfg, s := fromContext(cmd) + if !cfg.Backend.IsAdmin(s.PublicKey()) { + return ErrUnauthorized + } + return nil +} + +func checkIfCollab(cmd *cobra.Command, args []string) error { + var repo string + if len(args) > 0 { + repo = args[0] + } + cfg, s := fromContext(cmd) + rn := strings.TrimSuffix(repo, ".git") + auth := cfg.Access.AccessLevel(rn, s.PublicKey()) + if auth < backend.ReadWriteAccess { + return ErrUnauthorized + } + return nil +} + +// Middleware is the Soft Serve middleware that handles SSH commands. +func Middleware(cfg *config.Config) wish.Middleware { + return func(sh ssh.Handler) ssh.Handler { + return func(s ssh.Session) { + func() { + _, _, active := s.Pty() + if active { + return + } + ctx := context.WithValue(s.Context(), ConfigCtxKey, cfg) + ctx = context.WithValue(ctx, SessionCtxKey, s) + + rootCmd := rootCommand() + rootCmd.SetArgs(s.Command()) + if len(s.Command()) == 0 { + // otherwise it'll default to os.Args, which is not what we want. + rootCmd.SetArgs([]string{"--help"}) + } + rootCmd.SetIn(s) + rootCmd.SetOut(s) + rootCmd.CompletionOptions.DisableDefaultCmd = true + rootCmd.SetErr(s.Stderr()) + if err := rootCmd.ExecuteContext(ctx); err != nil { + _ = s.Exit(1) + } + }() + sh(s) + } + } +} diff --git a/server/cmd/create.go b/server/cmd/create.go new file mode 100644 index 000000000..89c261d12 --- /dev/null +++ b/server/cmd/create.go @@ -0,0 +1,26 @@ +package cmd + +import "github.com/spf13/cobra" + +// createCommand is the command for creating a new repository. +func createCommand() *cobra.Command { + var private bool + var description string + cmd := &cobra.Command{ + Use: "create REPOSITORY", + Short: "Create a new repository.", + Args: cobra.ExactArgs(1), + PersistentPreRunE: checkIfAdmin, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, _ := fromContext(cmd) + name := args[0] + if _, err := cfg.Backend.CreateRepository(name, private); err != nil { + return err + } + return nil + }, + } + cmd.Flags().BoolVarP(&private, "private", "p", false, "make the repository private") + cmd.Flags().StringVarP(&description, "description", "d", "", "set the repository description") + return cmd +} diff --git a/server/cmd/delete.go b/server/cmd/delete.go new file mode 100644 index 000000000..8dd94a645 --- /dev/null +++ b/server/cmd/delete.go @@ -0,0 +1,22 @@ +package cmd + +import "github.com/spf13/cobra" + +func deleteCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete REPOSITORY", + Aliases: []string{"del", "remove", "rm"}, + Short: "Delete a repository.", + Args: cobra.ExactArgs(1), + PersistentPreRunE: checkIfAdmin, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, _ := fromContext(cmd) + name := args[0] + if err := cfg.Backend.DeleteRepository(name); err != nil { + return err + } + return nil + }, + } + return cmd +} diff --git a/server/cmd/description.go b/server/cmd/description.go new file mode 100644 index 000000000..2eb59c5eb --- /dev/null +++ b/server/cmd/description.go @@ -0,0 +1,40 @@ +package cmd + +import ( + "strings" + + "github.com/spf13/cobra" +) + +func descriptionCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "description REPOSITORY [DESCRIPTION]", + Aliases: []string{"desc"}, + Short: "Set or get the description for a repository.", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, _ := fromContext(cmd) + rn := strings.TrimSuffix(args[0], ".git") + switch len(args) { + case 1: + if err := checkIfReadable(cmd, args); err != nil { + return err + } + + desc := cfg.Backend.Description(rn) + cmd.Println(desc) + default: + if err := checkIfCollab(cmd, args); err != nil { + return err + } + if err := cfg.Backend.SetDescription(rn, strings.Join(args[1:], " ")); err != nil { + return err + } + } + + return nil + }, + } + + return cmd +} diff --git a/server/cmd/list.go b/server/cmd/list.go new file mode 100644 index 000000000..0bec7fd19 --- /dev/null +++ b/server/cmd/list.go @@ -0,0 +1,95 @@ +package cmd + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/server/backend" + "github.com/spf13/cobra" +) + +// listCommand returns a command that list file or directory at path. +func listCommand() *cobra.Command { + listCmd := &cobra.Command{ + Use: "list PATH", + Aliases: []string{"ls"}, + Short: "List files at repository.", + Args: cobra.RangeArgs(0, 1), + PersistentPreRunE: checkIfReadable, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, s := fromContext(cmd) + rn := "" + path := "" + ps := []string{} + if len(args) > 0 { + path = filepath.Clean(args[0]) + ps = strings.Split(path, "/") + rn = strings.TrimSuffix(ps[0], ".git") + auth := cfg.Access.AccessLevel(rn, s.PublicKey()) + if auth < backend.ReadOnlyAccess { + return ErrUnauthorized + } + } + if path == "" || path == "." || path == "/" { + repos, err := cfg.Backend.Repositories() + if err != nil { + return err + } + for _, r := range repos { + if cfg.Access.AccessLevel(r.Name(), s.PublicKey()) >= backend.ReadOnlyAccess { + cmd.Println(r.Name()) + } + } + return nil + } + rr, err := cfg.Backend.Repository(rn) + if err != nil { + return err + } + r, err := rr.Open() + if err != nil { + return err + } + head, err := r.HEAD() + if err != nil { + if bs, err := r.Branches(); err != nil && len(bs) == 0 { + return fmt.Errorf("repository is empty") + } + return err + } + tree, err := r.TreePath(head, "") + if err != nil { + return err + } + subpath := strings.Join(ps[1:], "/") + ents := git.Entries{} + te, err := tree.TreeEntry(subpath) + if err == git.ErrRevisionNotExist { + return ErrFileNotFound + } + if err != nil { + return err + } + if te.Type() == "tree" { + tree, err = tree.SubTree(subpath) + if err != nil { + return err + } + ents, err = tree.Entries() + if err != nil { + return err + } + } else { + ents = append(ents, te) + } + ents.Sort() + for _, ent := range ents { + cmd.Printf("%s\t%d\t %s\n", ent.Mode(), ent.Size(), ent.Name()) + } + return nil + }, + } + return listCmd +} diff --git a/server/cmd/middleware.go b/server/cmd/middleware.go deleted file mode 100644 index 8635a6650..000000000 --- a/server/cmd/middleware.go +++ /dev/null @@ -1,47 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - - "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/ssh" - "github.com/charmbracelet/wish" -) - -// Middleware is the Soft Serve middleware that handles SSH commands. -func Middleware(cfg *config.Config) wish.Middleware { - return func(sh ssh.Handler) ssh.Handler { - return func(s ssh.Session) { - func() { - _, _, active := s.Pty() - if active { - return - } - ctx := context.WithValue(s.Context(), ConfigCtxKey, cfg) - ctx = context.WithValue(ctx, SessionCtxKey, s) - - use := "ssh" - port := cfg.Backend.ServerPort() - if port != "22" { - use += fmt.Sprintf(" -p%s", port) - } - use += fmt.Sprintf(" %s", cfg.Backend.ServerHost()) - cmd := RootCommand() - cmd.Use = use - cmd.CompletionOptions.DisableDefaultCmd = true - cmd.SetIn(s) - cmd.SetOut(s) - cmd.SetErr(s.Stderr()) - cmd.SetArgs(s.Command()) - err := cmd.ExecuteContext(ctx) - if err != nil { - _, _ = s.Write([]byte(err.Error())) - _ = s.Exit(1) - return - } - }() - sh(s) - } - } -} diff --git a/server/cmd/private.go b/server/cmd/private.go new file mode 100644 index 000000000..593845dfb --- /dev/null +++ b/server/cmd/private.go @@ -0,0 +1,44 @@ +package cmd + +import ( + "strconv" + "strings" + + "github.com/spf13/cobra" +) + +func privateCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "private REPOSITORY [true|false]", + Short: "Set or get a repository private property.", + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, _ := fromContext(cmd) + rn := strings.TrimSuffix(args[0], ".git") + + switch len(args) { + case 1: + if err := checkIfReadable(cmd, args); err != nil { + return err + } + + isPrivate := cfg.Backend.IsPrivate(rn) + cmd.Println(isPrivate) + case 2: + isPrivate, err := strconv.ParseBool(args[1]) + if err != nil { + return err + } + if err := checkIfCollab(cmd, args); err != nil { + return err + } + if err := cfg.Backend.SetPrivate(rn, isPrivate); err != nil { + return err + } + } + return nil + }, + } + + return cmd +} diff --git a/server/cmd/rename.go b/server/cmd/rename.go new file mode 100644 index 000000000..a5b88d83a --- /dev/null +++ b/server/cmd/rename.go @@ -0,0 +1,23 @@ +package cmd + +import "github.com/spf13/cobra" + +func renameCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "rename REPOSITORY NEW_NAME", + Short: "Rename an existing repository.", + Args: cobra.ExactArgs(2), + PersistentPreRunE: checkIfCollab, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, _ := fromContext(cmd) + oldName := args[0] + newName := args[1] + if err := cfg.Backend.RenameRepository(oldName, newName); err != nil { + return err + } + return nil + }, + } + + return cmd +} diff --git a/server/cmd/repo.go b/server/cmd/repo.go deleted file mode 100644 index c2324bee7..000000000 --- a/server/cmd/repo.go +++ /dev/null @@ -1,408 +0,0 @@ -package cmd - -import ( - "fmt" - "path/filepath" - "strconv" - "strings" - - "github.com/alecthomas/chroma/lexers" - gansi "github.com/charmbracelet/glamour/ansi" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/server/backend" - "github.com/charmbracelet/soft-serve/ui/common" - "github.com/muesli/termenv" - "github.com/spf13/cobra" -) - -// RepoCommand is the command for managing repositories. -func RepoCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "repo COMMAND", - Aliases: []string{"repository", "repositories"}, - Short: "Manage repositories.", - } - cmd.AddCommand( - setCommand(), - createCommand(), - deleteCommand(), - listCommand(), - showCommand(), - ) - return cmd -} - -func setCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "set", - Short: "Set repository properties.", - } - cmd.AddCommand( - setName(), - setDescription(), - setPrivate(), - setDefaultBranch(), - ) - return cmd -} - -// createCommand is the command for creating a new repository. -func createCommand() *cobra.Command { - var private bool - var description string - var projectName string - cmd := &cobra.Command{ - Use: "create REPOSITORY", - Short: "Create a new repository.", - Args: cobra.ExactArgs(1), - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - cfg, s := fromContext(cmd) - if !cfg.Backend.IsAdmin(s.PublicKey()) { - return ErrUnauthorized - } - return nil - }, - RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) - name := args[0] - if _, err := cfg.Backend.CreateRepository(name, private); err != nil { - return err - } - return nil - }, - } - cmd.Flags().BoolVarP(&private, "private", "p", false, "make the repository private") - cmd.Flags().StringVarP(&description, "description", "d", "", "set the repository description") - cmd.Flags().StringVarP(&projectName, "project-name", "n", "", "set the project name") - return cmd -} - -func deleteCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "delete REPOSITORY", - Short: "Delete a repository.", - Args: cobra.ExactArgs(1), - PersistentPreRunE: checkIfAdmin, - RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) - name := args[0] - if err := cfg.Backend.DeleteRepository(name); err != nil { - return err - } - return nil - }, - } - return cmd -} - -func checkIfReadable(cmd *cobra.Command, args []string) error { - var repo string - if len(args) > 0 { - repo = args[0] - } - cfg, s := fromContext(cmd) - rn := strings.TrimSuffix(repo, ".git") - auth := cfg.Access.AccessLevel(rn, s.PublicKey()) - if auth < backend.ReadOnlyAccess { - return ErrUnauthorized - } - return nil -} - -func checkIfAdmin(cmd *cobra.Command, args []string) error { - cfg, s := fromContext(cmd) - if !cfg.Backend.IsAdmin(s.PublicKey()) { - return ErrUnauthorized - } - return nil -} - -func checkIfCollab(cmd *cobra.Command, args []string) error { - var repo string - if len(args) > 0 { - repo = args[0] - } - cfg, s := fromContext(cmd) - rn := strings.TrimSuffix(repo, ".git") - auth := cfg.Access.AccessLevel(rn, s.PublicKey()) - if auth < backend.ReadWriteAccess { - return ErrUnauthorized - } - return nil -} - -func setName() *cobra.Command { - cmd := &cobra.Command{ - Use: "name REPOSITORY NEW_NAME", - Short: "Set the name for a repository.", - Args: cobra.ExactArgs(2), - PersistentPreRunE: checkIfAdmin, - RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) - oldName := args[0] - newName := args[1] - if err := cfg.Backend.RenameRepository(oldName, newName); err != nil { - return err - } - return nil - }, - } - return cmd -} - -func setDescription() *cobra.Command { - cmd := &cobra.Command{ - Use: "description REPOSITORY DESCRIPTION", - Short: "Set the description for a repository.", - Args: cobra.MinimumNArgs(2), - PersistentPreRunE: checkIfCollab, - RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) - rn := strings.TrimSuffix(args[0], ".git") - if err := cfg.Backend.SetDescription(rn, strings.Join(args[1:], " ")); err != nil { - return err - } - return nil - }, - } - return cmd -} - -func setPrivate() *cobra.Command { - cmd := &cobra.Command{ - Use: "private REPOSITORY [true|false]", - Short: "Set a repository to private.", - Args: cobra.ExactArgs(2), - PersistentPreRunE: checkIfCollab, - RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) - rn := strings.TrimSuffix(args[0], ".git") - isPrivate, err := strconv.ParseBool(args[1]) - if err != nil { - return err - } - if err := cfg.Backend.SetPrivate(rn, isPrivate); err != nil { - return err - } - return nil - }, - } - return cmd -} - -func setDefaultBranch() *cobra.Command { - cmd := &cobra.Command{ - Use: "default-branch REPOSITORY BRANCH", - Short: "Set the default branch for a repository.", - Args: cobra.ExactArgs(2), - PersistentPreRunE: checkIfAdmin, - RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := fromContext(cmd) - rn := strings.TrimSuffix(args[0], ".git") - if err := cfg.Backend.SetDefaultBranch(rn, args[1]); err != nil { - return err - } - return nil - }, - } - return cmd -} - -// listCommand returns a command that list file or directory at path. -func listCommand() *cobra.Command { - listCmd := &cobra.Command{ - Use: "list PATH", - Aliases: []string{"ls"}, - Short: "List file or directory at path.", - Args: cobra.RangeArgs(0, 1), - PersistentPreRunE: checkIfReadable, - RunE: func(cmd *cobra.Command, args []string) error { - cfg, s := fromContext(cmd) - rn := "" - path := "" - ps := []string{} - if len(args) > 0 { - path = filepath.Clean(args[0]) - ps = strings.Split(path, "/") - rn = strings.TrimSuffix(ps[0], ".git") - auth := cfg.Access.AccessLevel(rn, s.PublicKey()) - if auth < backend.ReadOnlyAccess { - return ErrUnauthorized - } - } - if path == "" || path == "." || path == "/" { - repos, err := cfg.Backend.Repositories() - if err != nil { - return err - } - for _, r := range repos { - if cfg.Access.AccessLevel(r.Name(), s.PublicKey()) >= backend.ReadOnlyAccess { - fmt.Fprintln(s, r.Name()) - } - } - return nil - } - rr, err := cfg.Backend.Repository(rn) - if err != nil { - return err - } - r, err := rr.Repository() - if err != nil { - return err - } - head, err := r.HEAD() - if err != nil { - if bs, err := r.Branches(); err != nil && len(bs) == 0 { - return fmt.Errorf("repository is empty") - } - return err - } - tree, err := r.TreePath(head, "") - if err != nil { - return err - } - subpath := strings.Join(ps[1:], "/") - ents := git.Entries{} - te, err := tree.TreeEntry(subpath) - if err == git.ErrRevisionNotExist { - return ErrFileNotFound - } - if err != nil { - return err - } - if te.Type() == "tree" { - tree, err = tree.SubTree(subpath) - if err != nil { - return err - } - ents, err = tree.Entries() - if err != nil { - return err - } - } else { - ents = append(ents, te) - } - ents.Sort() - for _, ent := range ents { - fmt.Fprintf(s, "%s\t%d\t %s\n", ent.Mode(), ent.Size(), ent.Name()) - } - return nil - }, - } - return listCmd -} - -var ( - lineDigitStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("239")) - lineBarStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("236")) - dirnameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00AAFF")) - filenameStyle = lipgloss.NewStyle() - filemodeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777")) -) - -// showCommand returns a command that prints the contents of a file. -func showCommand() *cobra.Command { - var linenumber bool - var color bool - - showCmd := &cobra.Command{ - Use: "show PATH", - Aliases: []string{"cat"}, - Short: "Outputs the contents of the file at path.", - Args: cobra.ExactArgs(1), - PersistentPreRunE: checkIfReadable, - RunE: func(cmd *cobra.Command, args []string) error { - cfg, s := fromContext(cmd) - ps := strings.Split(args[0], "/") - rn := strings.TrimSuffix(ps[0], ".git") - fp := strings.Join(ps[1:], "/") - auth := cfg.Access.AccessLevel(rn, s.PublicKey()) - if auth < backend.ReadOnlyAccess { - return ErrUnauthorized - } - var repo backend.Repository - repoExists := false - repos, err := cfg.Backend.Repositories() - if err != nil { - return err - } - for _, rp := range repos { - if rp.Name() == rn { - repoExists = true - repo = rp - break - } - } - if !repoExists { - return ErrRepoNotFound - } - c, _, err := backend.LatestFile(repo, fp) - if err != nil { - return err - } - if color { - c, err = withFormatting(fp, c) - if err != nil { - return err - } - } - if linenumber { - c = withLineNumber(c, color) - } - fmt.Fprint(s, c) - return nil - }, - } - showCmd.Flags().BoolVarP(&linenumber, "linenumber", "l", false, "Print line numbers") - showCmd.Flags().BoolVarP(&color, "color", "c", false, "Colorize output") - - return showCmd -} - -func withLineNumber(s string, color bool) string { - lines := strings.Split(s, "\n") - // NB: len() is not a particularly safe way to count string width (because - // it's counting bytes instead of runes) but in this case it's okay - // because we're only dealing with digits, which are one byte each. - mll := len(fmt.Sprintf("%d", len(lines))) - for i, l := range lines { - digit := fmt.Sprintf("%*d", mll, i+1) - bar := "│" - if color { - digit = lineDigitStyle.Render(digit) - bar = lineBarStyle.Render(bar) - } - if i < len(lines)-1 || len(l) != 0 { - // If the final line was a newline we'll get an empty string for - // the final line, so drop the newline altogether. - lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l) - } - } - return strings.Join(lines, "\n") -} - -func withFormatting(p, c string) (string, error) { - zero := uint(0) - lang := "" - lexer := lexers.Match(p) - if lexer != nil && lexer.Config() != nil { - lang = lexer.Config().Name - } - formatter := &gansi.CodeBlockElement{ - Code: c, - Language: lang, - } - r := strings.Builder{} - styles := common.StyleConfig() - styles.CodeBlock.Margin = &zero - rctx := gansi.NewRenderContext(gansi.Options{ - Styles: styles, - ColorProfile: termenv.TrueColor, - }) - err := formatter.Render(&r, rctx) - if err != nil { - return "", err - } - return r.String(), nil -} diff --git a/server/cmd/show.go b/server/cmd/show.go new file mode 100644 index 000000000..cab515e4f --- /dev/null +++ b/server/cmd/show.go @@ -0,0 +1,128 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/alecthomas/chroma/lexers" + gansi "github.com/charmbracelet/glamour/ansi" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/soft-serve/server/backend" + "github.com/charmbracelet/soft-serve/ui/common" + "github.com/muesli/termenv" + "github.com/spf13/cobra" +) + +var ( + lineDigitStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("239")) + lineBarStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("236")) + dirnameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00AAFF")) + filenameStyle = lipgloss.NewStyle() + filemodeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777")) +) + +// showCommand returns a command that prints the contents of a file. +func showCommand() *cobra.Command { + var linenumber bool + var color bool + + showCmd := &cobra.Command{ + Use: "show PATH", + Aliases: []string{"cat"}, + Short: "Read the contents of file at path.", + Args: cobra.ExactArgs(1), + PersistentPreRunE: checkIfReadable, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, s := fromContext(cmd) + ps := strings.Split(args[0], "/") + rn := strings.TrimSuffix(ps[0], ".git") + fp := strings.Join(ps[1:], "/") + auth := cfg.Access.AccessLevel(rn, s.PublicKey()) + if auth < backend.ReadOnlyAccess { + return ErrUnauthorized + } + var repo backend.Repository + repoExists := false + repos, err := cfg.Backend.Repositories() + if err != nil { + return err + } + for _, rp := range repos { + if rp.Name() == rn { + repoExists = true + repo = rp + break + } + } + if !repoExists { + return ErrRepoNotFound + } + c, _, err := backend.LatestFile(repo, fp) + if err != nil { + return err + } + if color { + c, err = withFormatting(fp, c) + if err != nil { + return err + } + } + if linenumber { + c = withLineNumber(c, color) + } + cmd.Println(c) + return nil + }, + } + showCmd.Flags().BoolVarP(&linenumber, "linenumber", "l", false, "Print line numbers") + showCmd.Flags().BoolVarP(&color, "color", "c", false, "Colorize output") + + return showCmd +} + +func withLineNumber(s string, color bool) string { + lines := strings.Split(s, "\n") + // NB: len() is not a particularly safe way to count string width (because + // it's counting bytes instead of runes) but in this case it's okay + // because we're only dealing with digits, which are one byte each. + mll := len(fmt.Sprintf("%d", len(lines))) + for i, l := range lines { + digit := fmt.Sprintf("%*d", mll, i+1) + bar := "│" + if color { + digit = lineDigitStyle.Render(digit) + bar = lineBarStyle.Render(bar) + } + if i < len(lines)-1 || len(l) != 0 { + // If the final line was a newline we'll get an empty string for + // the final line, so drop the newline altogether. + lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l) + } + } + return strings.Join(lines, "\n") +} + +func withFormatting(p, c string) (string, error) { + zero := uint(0) + lang := "" + lexer := lexers.Match(p) + if lexer != nil && lexer.Config() != nil { + lang = lexer.Config().Name + } + formatter := &gansi.CodeBlockElement{ + Code: c, + Language: lang, + } + r := strings.Builder{} + styles := common.StyleConfig() + styles.CodeBlock.Margin = &zero + rctx := gansi.NewRenderContext(gansi.Options{ + Styles: styles, + ColorProfile: termenv.TrueColor, + }) + err := formatter.Render(&r, rctx) + if err != nil { + return "", err + } + return r.String(), nil +} diff --git a/server/cmd/tag.go b/server/cmd/tag.go new file mode 100644 index 000000000..84e6a907d --- /dev/null +++ b/server/cmd/tag.go @@ -0,0 +1,80 @@ +package cmd + +import ( + "strings" + + "github.com/spf13/cobra" +) + +func tagCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "tag", + Short: "Manage repository tags", + } + + cmd.AddCommand( + tagListCommand(), + tagDeleteCommand(), + ) + + return cmd +} + +func tagListCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "list REPOSITORY", + Aliases: []string{"ls"}, + Short: "List repository tags", + Args: cobra.ExactArgs(1), + PersistentPreRunE: checkIfReadable, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, _ := fromContext(cmd) + rn := strings.TrimSuffix(args[0], ".git") + rr, err := cfg.Backend.Repository(rn) + if err != nil { + return err + } + + r, err := rr.Open() + if err != nil { + return err + } + + tags, _ := r.Tags() + for _, t := range tags { + cmd.Println(t) + } + + return nil + }, + } + + return cmd +} + +func tagDeleteCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete REPOSITORY TAG", + Aliases: []string{"remove", "rm", "del"}, + Short: "Delete a tag", + Args: cobra.ExactArgs(2), + PersistentPreRunE: checkIfCollab, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, _ := fromContext(cmd) + rn := strings.TrimSuffix(args[0], ".git") + rr, err := cfg.Backend.Repository(rn) + if err != nil { + return err + } + + r, err := rr.Open() + if err != nil { + return err + } + + return r.DeleteTag(args[1]) + }, + } + + return cmd +} diff --git a/ui/pages/repo/files.go b/ui/pages/repo/files.go index 83f01d6dd..d634bc9a1 100644 --- a/ui/pages/repo/files.go +++ b/ui/pages/repo/files.go @@ -331,7 +331,7 @@ func (f *Files) updateFilesCmd() tea.Msg { log.Printf("ui: files: ref is nil") return common.ErrorMsg(errNoRef) } - r, err := f.repo.Repository() + r, err := f.repo.Open() if err != nil { return common.ErrorMsg(err) } diff --git a/ui/pages/repo/log.go b/ui/pages/repo/log.go index 6b5546590..30bac3f73 100644 --- a/ui/pages/repo/log.go +++ b/ui/pages/repo/log.go @@ -388,7 +388,7 @@ func (l *Log) countCommitsCmd() tea.Msg { logger.Debugf("ui: log: ref is nil") return common.ErrorMsg(errNoRef) } - r, err := l.repo.Repository() + r, err := l.repo.Open() if err != nil { return common.ErrorMsg(err) } @@ -418,7 +418,7 @@ func (l *Log) updateCommitsCmd() tea.Msg { page := l.nextPage limit := l.selector.PerPage() skip := page * limit - r, err := l.repo.Repository() + r, err := l.repo.Open() if err != nil { return common.ErrorMsg(err) } @@ -445,7 +445,7 @@ func (l *Log) selectCommitCmd(commit *git.Commit) tea.Cmd { } func (l *Log) loadDiffCmd() tea.Msg { - r, err := l.repo.Repository() + r, err := l.repo.Open() if err != nil { logger.Debugf("ui: error loading diff repository: %v", err) return common.ErrorMsg(err) diff --git a/ui/pages/repo/refs.go b/ui/pages/repo/refs.go index aa4e81f94..d107264e0 100644 --- a/ui/pages/repo/refs.go +++ b/ui/pages/repo/refs.go @@ -175,7 +175,7 @@ func (r *Refs) StatusBarInfo() string { func (r *Refs) updateItemsCmd() tea.Msg { its := make(RefItems, 0) - rr, err := r.repo.Repository() + rr, err := r.repo.Open() if err != nil { return common.ErrorMsg(err) } @@ -218,7 +218,7 @@ func switchRefCmd(ref *ggit.Reference) tea.Cmd { // UpdateRefCmd gets the repository's HEAD reference and sends a RefMsg. func UpdateRefCmd(repo backend.Repository) tea.Cmd { return func() tea.Msg { - r, err := repo.Repository() + r, err := repo.Open() if err != nil { return common.ErrorMsg(err) } diff --git a/ui/pages/selection/item.go b/ui/pages/selection/item.go index 8e9abd69c..596ddcef2 100644 --- a/ui/pages/selection/item.go +++ b/ui/pages/selection/item.go @@ -56,7 +56,7 @@ type Item struct { // New creates a new Item. func NewItem(repo backend.Repository, cfg *config.Config) (Item, error) { - r, err := repo.Repository() + r, err := repo.Open() if err != nil { return Item{}, err }