diff --git a/cmd/commands/auth/login/login.go b/cmd/commands/auth/login/login.go index 32c3b83..204bd78 100644 --- a/cmd/commands/auth/login/login.go +++ b/cmd/commands/auth/login/login.go @@ -2,24 +2,33 @@ package login import ( "fmt" + "path/filepath" + "github.com/craftamap/bb/config" "github.com/craftamap/bb/util/logging" "github.com/AlecAivazis/survey/v2" "github.com/craftamap/bb/cmd/options" "github.com/logrusorgru/aurora" "github.com/spf13/cobra" - "github.com/spf13/viper" ) -func Add(authCmd *cobra.Command, globalOpts *options.GlobalOptions) { +func Add(authCmd *cobra.Command, _ *options.GlobalOptions) { loginCmd := &cobra.Command{ Use: "login", - Run: func(cmd *cobra.Command, args []string) { - oldPw := viper.GetString("password") + Run: func(_ *cobra.Command, _ []string) { + configDirectory, filename := config.GetGlobalConfigurationPath() + path := filepath.Join(configDirectory, filename) + tmpVp, err := config.GetViperForPath(path) + if err != nil { + logging.Error(err) + return + } + + oldPw := tmpVp.GetString(config.CONFIG_KEY_AUTH_PASSWORD) if oldPw != "" { - fmt.Println(aurora.Yellow("::"), aurora.Bold("Warning:"), "You are already logged in as", viper.GetString("username")) + logging.Warning("You are already logged in as ", tmpVp.GetString(config.CONFIG_KEY_AUTH_USERNAME)) cont := false err := survey.AskOne(&survey.Confirm{Message: "Do you want to overwrite this?"}, &cont) if err != nil { @@ -41,7 +50,7 @@ func Add(authCmd *cobra.Command, globalOpts *options.GlobalOptions) { Password string }{} - err := survey.Ask([]*survey.Question{ + err = survey.Ask([]*survey.Question{ { Name: "username", Prompt: &survey.Input{ @@ -60,17 +69,18 @@ func Add(authCmd *cobra.Command, globalOpts *options.GlobalOptions) { logging.Error(err) return } - - viper.Set("username", answers.Username) - viper.Set("password", answers.Password) - - err = viper.WriteConfig() + _, err = config.ValidateAndUpdateEntryWithViper(tmpVp, config.CONFIG_KEY_AUTH_USERNAME, answers.Username) + if err != nil { + logging.Error(err) + return + } + _, err = config.ValidateAndUpdateEntryWithViper(tmpVp, config.CONFIG_KEY_AUTH_PASSWORD, answers.Password) if err != nil { logging.Error(err) return } - logging.Success(fmt.Sprint("Stored credentials successfully to", viper.ConfigFileUsed())) + logging.Success(fmt.Sprint("Stored credentials successfully to", tmpVp.ConfigFileUsed())) }, } diff --git a/cmd/commands/config/config.go b/cmd/commands/config/config.go new file mode 100644 index 0000000..271d420 --- /dev/null +++ b/cmd/commands/config/config.go @@ -0,0 +1,220 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/craftamap/bb/cmd/options" + "github.com/craftamap/bb/config" + "github.com/craftamap/bb/util/logging" + "github.com/spf13/cobra" +) + +var ( + Local bool + Get bool + GetAll bool +) + +func Add(rootCmd *cobra.Command, _ *options.GlobalOptions) { + configCommand := cobra.Command{ + Use: "config", + Short: "configure bb", + Long: fmt.Sprintf(`configure bb and change it's behaviour. +bb sources configuration values from multiple sources: + 1. The global configuration (usually located at $HOME/.config/bb/configuration.toml) + 2. The local configuration (a .bb file in your repository root) + 3. Environment variables + 4. command-line flags +This command allows you to modify and retrieve the configuration values without editing the configuration values by yourself. + +The following keys are supported: + %s`, strings.Join(config.ConfigKeys, ", ")), + PreRunE: func(_ *cobra.Command, _ []string) error { + if Get && GetAll { + logging.Error("--get and --get-all are mutually exclusive") + return fmt.Errorf("") // FIXME: return empty error, so the command fails, but we can use our own method to print out the error message + } + return nil + }, + Args: func(cmd *cobra.Command, args []string) error { + if GetAll { + return cobra.ExactArgs(0)(cmd, args) + } else if Get { + return cobra.ExactArgs(1)(cmd, args) + } else { + return cobra.ExactArgs(2)(cmd, args) + } + }, + Run: func(_ *cobra.Command, args []string) { + if GetAll { + GetAllValues(args) + } else if Get { + GetValue(args) + } else { + SetValue(args) + } + }, + } + + configCommand.Flags().BoolVar(&Local, "local", false, "modify or retrieve the local configuration") + configCommand.Flags().BoolVar(&Get, "get", false, "gets a configuration value instead of setting it") + configCommand.Flags().BoolVar(&GetAll, "get-all", false, "prints out all configuration values of the selected configuration") + + rootCmd.AddCommand(&configCommand) +} + +func GetAllValues(_ []string) { + var configDirectory string + var filename string + if Local { + var err error + configDirectory, filename, err = config.GetLocalConfigurationPath() + if err != nil { + logging.Error(err) + return + } + } else { + configDirectory, filename = config.GetGlobalConfigurationPath() + } + path := filepath.Join(configDirectory, filename) + tmpVp, err := config.GetViperForPath(path) + if err != nil { + logging.Error(err) + return + } + for key, entry := range config.BbConfigurationValidation { + value := tmpVp.Get(key) + if value == nil { + continue + } + if entry.Hidden { + value = "(hidden)" + } + + fmt.Printf("%s = %s\n", key, value) + } +} + +func SetValue(args []string) { + key := args[0] + inputValue := args[1] + + newValue, err := config.BbConfigurationValidation.ValidateEntry(key, inputValue) + + if err != nil { + logging.Error(fmt.Sprintf("failed to validate %s: %s", inputValue, err)) + return + } + + var configDirectory string + var filename string + if Local { + var err error + configDirectory, filename, err = config.GetLocalConfigurationPath() + if err != nil { + logging.Error(err) + return + } + } else { + configDirectory, filename = config.GetGlobalConfigurationPath() + } + + // If the directory does not exist, something is off: + // - The global configuration directory get's created in root + // - The local configuration directory is a repository, which always exists + if _, err := os.Stat(configDirectory); os.IsNotExist(err) { + logging.Error(fmt.Sprintf("Expected directory \"%s\", but the directory does not exist", configDirectory)) + return + } + path := filepath.Join(configDirectory, filename) + // If the config itself does not exist, it's fine (although weird for global) - we create it now + if _, err := os.Stat(path); os.IsNotExist(err) { + logging.Note(fmt.Sprintf("Creating config file %s", path)) + fh, err := os.Create(path) + if err != nil { + logging.Error(fmt.Sprintf("Unable to create file %s", path)) + } + fh.Close() + } + + logging.Debugf("Config file path: %s", path) + + tmpVp, err := config.GetViperForPath(path) + if err != nil { + logging.Error(err) + return + } + + isSetAlready := tmpVp.IsSet(key) + oldValue := tmpVp.Get(key) + + if isSetAlready { + // Don't print old password values + if config.BbConfigurationValidation[key].Hidden { + oldValue = "(hidden)" + } + logging.Warning(fmt.Sprintf("\"%s\" is already set. This will overwrite the value of \"%s\" from \"%s\" to \"%s\".", key, key, oldValue, newValue)) + } + + logging.Note(fmt.Sprintf("Setting \"%s\" to \"%s\" in %s", key, newValue, path)) + logging.Debugf("%+v", tmpVp.AllSettings()) + + tmpVp.Set(key, newValue) + logging.Debugf("%+v", tmpVp.AllSettings()) + + err = config.WriteViper(tmpVp, path) + if err != nil { + logging.Error(err) + return + } + + logging.SuccessExclamation(fmt.Sprintf("Successfully updated configuration %s", path)) +} + +func GetValue(args []string) { + key := args[0] + + entry, ok := config.BbConfigurationValidation[key] + if !ok { + logging.Warning(fmt.Sprintf("\"%s\" is not a valid key", key)) + return + } + + var configDirectory string + var filename string + if Local { + var err error + configDirectory, filename, err = config.GetLocalConfigurationPath() + if err != nil { + logging.Error(err) + return + } + } else { + configDirectory, filename = config.GetGlobalConfigurationPath() + } + + path := filepath.Join(configDirectory, filename) + if _, err := os.Stat(path); os.IsNotExist(err) { + logging.Error(fmt.Sprintf("config file %s does not exist yet", path)) + return + } + + tmpVp, err := config.GetViperForPath(path) + if err != nil { + logging.Error(err) + return + } + value := tmpVp.Get(key) + if value == nil { + logging.Warning(fmt.Sprintf("%s is not set yet.", key)) + return + } + if entry.Hidden { + value = "(hidden)" + } + + logging.Success(fmt.Sprintf("%s = %s", key, value)) +} diff --git a/cmd/commands/pr/sync/sync.go b/cmd/commands/pr/sync/sync.go index b7f2522..62f507e 100644 --- a/cmd/commands/pr/sync/sync.go +++ b/cmd/commands/pr/sync/sync.go @@ -10,6 +10,7 @@ import ( "github.com/cli/cli/utils" "github.com/cli/safeexec" "github.com/craftamap/bb/cmd/options" + "github.com/craftamap/bb/config" "github.com/craftamap/bb/internal/run" "github.com/craftamap/bb/util/logging" "github.com/logrusorgru/aurora" @@ -38,7 +39,7 @@ func Add(prCmd *cobra.Command, globalOpts *options.GlobalOptions) { }, PreRunE: func(cmd *cobra.Command, _ []string) error { // In order to check if the method exists in the config, we need to check here - syncMethodIsSet := viper.IsSet("sync-method") + syncMethodIsSet := viper.IsSet(config.CONFIG_KEY_PR_SYNC_SYNC_METHOD) if !syncMethodIsSet { logging.Note( "You can configure your preferred way of syncing by adding the following line to your configuration: ", @@ -48,7 +49,7 @@ func Add(prCmd *cobra.Command, globalOpts *options.GlobalOptions) { ) } if syncMethodIsSet && !cmd.Flags().Lookup("method").Changed { - Method = viper.GetString("sync-method") + Method = viper.GetString(config.CONFIG_KEY_PR_SYNC_SYNC_METHOD) } if Method != MethodOptionRebase && Method != MethodOptionMerge { diff --git a/cmd/commands/repo/clone/clone.go b/cmd/commands/repo/clone/clone.go index 57afc91..bf989ad 100644 --- a/cmd/commands/repo/clone/clone.go +++ b/cmd/commands/repo/clone/clone.go @@ -2,8 +2,10 @@ package clone import ( "fmt" + "path/filepath" "strings" + "github.com/craftamap/bb/config" "github.com/craftamap/bb/util/logging" "github.com/AlecAivazis/survey/v2" @@ -24,7 +26,7 @@ func Add(repoCmd *cobra.Command, globalOpts *options.GlobalOptions) { Run: func(cmd *cobra.Command, args []string) { c := globalOpts.Client - gitProtocol := viper.GetString("git_protocol") + gitProtocol := viper.GetString(config.CONFIG_KEY_REPO_CLONE_GIT_PROTOCOL) if gitProtocol == "" || (gitProtocol != "ssh" && gitProtocol != "https") { err := survey.AskOne(&survey.Select{ Message: "Please select a prefered protocol of cloning repositories", @@ -34,8 +36,14 @@ func Add(repoCmd *cobra.Command, globalOpts *options.GlobalOptions) { logging.Error(err) return } - viper.Set("git_protocol", gitProtocol) - viper.WriteConfig() + + configDirectory, filename := config.GetGlobalConfigurationPath() + path := filepath.Join(configDirectory, filename) + _, err = config.ValidateAndUpdateEntry(path, config.CONFIG_KEY_REPO_CLONE_GIT_PROTOCOL, gitProtocol) + if err != nil { + logging.Error(err) + return + } } if len(args) == 0 { diff --git a/cmd/root.go b/cmd/root.go index bbaf408..2fcc405 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -10,12 +10,14 @@ import ( "github.com/craftamap/bb/client" "github.com/craftamap/bb/cmd/commands/api" "github.com/craftamap/bb/cmd/commands/auth" + "github.com/craftamap/bb/cmd/commands/config" "github.com/craftamap/bb/cmd/commands/downloads" "github.com/craftamap/bb/cmd/commands/issue" "github.com/craftamap/bb/cmd/commands/pipelines" "github.com/craftamap/bb/cmd/commands/pr" "github.com/craftamap/bb/cmd/commands/repo" "github.com/craftamap/bb/cmd/options" + configuration "github.com/craftamap/bb/config" bbgit "github.com/craftamap/bb/git" "github.com/kirsle/configdir" "github.com/logrusorgru/aurora" @@ -33,8 +35,9 @@ var ( Long: "Work seamlessly with Bitbucket.org from the command line.", Example: `$ bb pr list`, PersistentPreRun: func(cmd *cobra.Command, args []string) { - username := viper.GetString("username") - password := viper.GetString("password") + username := viper.GetString(configuration.CONFIG_KEY_AUTH_USERNAME) + password := viper.GetString(configuration.CONFIG_KEY_AUTH_PASSWORD) + remoteName := viper.GetString(configuration.CONFIG_KEY_GIT_REMOTE) if _, ok := cmd.Annotations["RequiresRepository"]; ok { bbrepo, err := bbgit.GetBitbucketRepo(remoteName) @@ -85,15 +88,21 @@ func init() { rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.config/bb)") rootCmd.PersistentFlags().StringVar(&username, "username", "", "username") rootCmd.PersistentFlags().StringVar(&password, "password", "", "app password") - rootCmd.PersistentFlags().StringVar(&remoteName, "remote", "origin", "if you are in a repository and don't want to interact with the default origin, you can change it") + rootCmd.PersistentFlags().StringVar(&remoteName, "remote", "origin", "if you are in a repository and don't want to interact with the default remote, you can change it") rootCmd.PersistentFlags().BoolVar(&logging.PrintDebugLogs, "debug", false, "enabling this flag allows debug logs to be printed") - err := viper.BindPFlag("username", rootCmd.PersistentFlags().Lookup("username")) + err := viper.BindPFlag(configuration.CONFIG_KEY_AUTH_USERNAME, rootCmd.PersistentFlags().Lookup("username")) if err != nil { logging.Error(err) return } - err = viper.BindPFlag("password", rootCmd.PersistentFlags().Lookup("password")) + err = viper.BindPFlag(configuration.CONFIG_KEY_AUTH_PASSWORD, rootCmd.PersistentFlags().Lookup("password")) + if err != nil { + logging.Error(err) + return + } + + err = viper.BindPFlag(configuration.CONFIG_KEY_GIT_REMOTE, rootCmd.PersistentFlags().Lookup("remote")) if err != nil { logging.Error(err) return @@ -106,6 +115,7 @@ func init() { auth.Add(rootCmd, &globalOpts) repo.Add(rootCmd, &globalOpts) pipelines.Add(rootCmd, &globalOpts) + config.Add(rootCmd, &globalOpts) if CommitSHA != "" { vt := rootCmd.VersionTemplate() @@ -119,30 +129,60 @@ func init() { } func initConfig() { - if cfgFile == "" { - configDir := configdir.LocalConfig("bb") - err := configdir.MakePath(configDir) + viper.SetEnvPrefix("bb") + viper.AutomaticEnv() + + // We support setting the config file manually by running bb with --config. + if cfgFile != "" { + viper.SetConfigFile(cfgFile) + err := viper.ReadInConfig() + if err != nil { // Handle errors reading the config file + panic(fmt.Errorf("fatal error config file: %s", err)) + } + } else { + // create global config directory, first + configDirectory := configdir.LocalConfig("bb") + err := configdir.MakePath(configDirectory) if err != nil { panic(err) } - cfgFile = filepath.Join(configDir, "configuration.toml") - if _, err = os.Stat(cfgFile); os.IsNotExist(err) { - fh, err := os.Create(cfgFile) + globalConfigFilePath := filepath.Join(configDirectory, "configuration.toml") + // create global config directory, first + if _, err = os.Stat(globalConfigFilePath); os.IsNotExist(err) { + fh, err := os.Create(globalConfigFilePath) if err != nil { panic(err) } defer fh.Close() } - } - - viper.SetConfigFile(cfgFile) - - viper.SetEnvPrefix("bb") - viper.AutomaticEnv() - - err := viper.ReadInConfig() + viper.SetConfigType("toml") + viper.SetConfigName("configuration.toml") + viper.AddConfigPath(configDirectory) + err = viper.ReadInConfig() + if err != nil { // Handle errors reading the config file + panic(fmt.Errorf("fatal error config file: %s", err)) + } - if err != nil { // Handle errors reading the config file - panic(fmt.Errorf("fatal error config file: %s", err)) + // also read in local configuration + if repoPath, err := bbgit.RepoPath(); err == nil { + // the local configuration can be found in the root of a repository + // If we in a repository, check for the file + if _, err = os.Stat(filepath.Join(repoPath, ".bb")); err == nil { + viper.SetConfigType("toml") + viper.SetConfigName(".bb") + viper.AddConfigPath(repoPath) + err = viper.MergeInConfig() + if err != nil { // Handle errors reading the config file + panic(fmt.Errorf("fatal error config file: %s", err)) + } + } + } } + + // Register Aliases for backward compatibility + viper.RegisterAlias("username", configuration.CONFIG_KEY_AUTH_USERNAME) + viper.RegisterAlias("password", configuration.CONFIG_KEY_AUTH_PASSWORD) + viper.RegisterAlias("remote", configuration.CONFIG_KEY_GIT_REMOTE) + viper.RegisterAlias("git_protocol", configuration.CONFIG_KEY_REPO_CLONE_GIT_PROTOCOL) + viper.RegisterAlias("sync-method", configuration.CONFIG_KEY_PR_SYNC_SYNC_METHOD) } diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..bb46f28 --- /dev/null +++ b/config/config.go @@ -0,0 +1,199 @@ +package config + +import ( + "fmt" + "io" + "io/ioutil" + "os" + "strings" + + "github.com/craftamap/bb/util/logging" + "github.com/spf13/viper" +) + +const ( + CONFIG_KEY_AUTH_USERNAME = "auth.username" + CONFIG_KEY_AUTH_PASSWORD = "auth.password" + CONFIG_KEY_GIT_REMOTE = "git.remote" + CONFIG_KEY_REPO_CLONE_GIT_PROTOCOL = "repo.clone.git_protocol" + CONFIG_KEY_PR_SYNC_SYNC_METHOD = "pr.sync.sync_method" +) + +var ConfigKeys = []string{ + CONFIG_KEY_AUTH_USERNAME, + CONFIG_KEY_AUTH_PASSWORD, + CONFIG_KEY_GIT_REMOTE, + CONFIG_KEY_REPO_CLONE_GIT_PROTOCOL, + CONFIG_KEY_PR_SYNC_SYNC_METHOD, +} + +type Validator func(interface{}) (interface{}, error) + +// Enum is just a string of a list. +func EnumValidator(validValues ...string) Validator { + return func(inputValue interface{}) (interface{}, error) { + _, ok := inputValue.(string) + if !ok { + return "", fmt.Errorf("value \"%s\" is not a string, but of type %T", inputValue, inputValue) + } + isInList := false + for _, validValue := range validValues { + if inputValue == validValue { + isInList = true + break + } + } + if !isInList { + return "", fmt.Errorf("value \"%s\" is not a valid value. Valid Values are %s", inputValue, validValues) + } + + return inputValue, nil + } +} + +// SimpleStringValidator validates if a input is a "simple" string - only single-line strings are supported. +func SimpleStringValidator() Validator { + return func(inputValue interface{}) (interface{}, error) { + _, ok := inputValue.(string) + if !ok { + return "", fmt.Errorf("value \"%s\" is not a string, but of type %T", inputValue, inputValue) + } + + if strings.ContainsAny(inputValue.(string), "\r\n") { + return "", fmt.Errorf("value \"%s\" contains illegal line break", inputValue) + } + + return inputValue, nil + } +} + +// Entry contains all the data required for Validation and Convertion. +type Entry struct { + Validator Validator + Hidden bool +} + +type Configuration map[string]Entry + +var BbConfigurationValidation Configuration = map[string]Entry{ + CONFIG_KEY_AUTH_USERNAME: { + Validator: SimpleStringValidator(), + }, + CONFIG_KEY_AUTH_PASSWORD: { + Validator: SimpleStringValidator(), + Hidden: true, + }, + CONFIG_KEY_GIT_REMOTE: { + Validator: SimpleStringValidator(), + }, + CONFIG_KEY_REPO_CLONE_GIT_PROTOCOL: { + Validator: EnumValidator("ssh", "https"), + }, + CONFIG_KEY_PR_SYNC_SYNC_METHOD: { + Validator: EnumValidator("merge", "rebase"), + }, +} + +func (c Configuration) ValidateEntry(key string, value interface{}) (interface{}, error) { + e, ok := c[key] + if !ok { + return "", fmt.Errorf("key \"%s\" is not a valid key", key) + } + return e.Validator(value) +} + +func ValidateEntry(key string, value interface{}) (interface{}, error) { + return BbConfigurationValidation.ValidateEntry(key, value) +} + +func ValidateAndUpdateEntry(filepath string, key string, value interface{}) (interface{}, error) { + sanitizedValue, err := ValidateEntry(key, value) + if err != nil { + return "", err + } + + // TODO: Add a filename-to-tmpVp cache - this way, we can prevent creating a new viper every time we want to set a value + vp, err := GetViperForPath(filepath) + if err != nil { + return sanitizedValue, err + } + + vp.Set(key, sanitizedValue) + err = WriteViper(vp, filepath) + + return sanitizedValue, err +} + +func ValidateAndUpdateEntryWithViper(vp viper.Viper, key string, value interface{}) (interface{}, error) { + sanitizedValue, err := ValidateEntry(key, value) + if err != nil { + return "", err + } + + vp.Set(key, sanitizedValue) + err = WriteViper(vp, vp.ConfigFileUsed()) + + return sanitizedValue, err +} + +func WriteViper(vp viper.Viper, path string) error { + // WORKAROUND: currently, WriteConfig does not support writing to `.bb`-files despite setting SetConfigType. + // Therefore, we create a temporary file, write there, and try to copy the file over. + tmpFh, err := ioutil.TempFile(os.TempDir(), "bb-tmpconfig.*.toml") + if err != nil { + logging.Error("Failed to create temporary configuration file") + return err + } + tmpFilename := tmpFh.Name() + logging.Debugf("tmpFilename: %s", tmpFilename) + err = tmpFh.Close() + if err != nil { + logging.Error("Failed to create temporary configuration file") + return err + } + err = vp.WriteConfigAs(tmpFilename) + if err != nil { + logging.Error(fmt.Sprintf("Failed to write temporary config %s: %s", path, err)) + return err + } + err = copyFileContent(tmpFilename, path) + if err != nil { + logging.Error(fmt.Sprintf("Failed to write config %s -> %s: %s", tmpFilename, path, err)) + return err + } + return nil +} + +func copyFileContent(src string, dst string) error { + sourceFileStat, err := os.Stat(src) + if err != nil { + return err + } + + if !sourceFileStat.Mode().IsRegular() { + return fmt.Errorf("%s is not a regular file", src) + } + + source, err := os.Open(src) + if err != nil { + return err + } + defer source.Close() + + destination, err := os.Create(dst) // Create or trunicate + if err != nil { + return err + } + defer destination.Close() + _, err = io.Copy(destination, source) + return err +} + +func GetViperForPath(path string) (viper.Viper, error) { + tmpVp := viper.New() + tmpVp.SetConfigType("toml") + tmpVp.SetConfigFile(path) + err := tmpVp.ReadInConfig() + + return *tmpVp, err +} diff --git a/config/path.go b/config/path.go new file mode 100644 index 0000000..8c8eece --- /dev/null +++ b/config/path.go @@ -0,0 +1,16 @@ +package config + +import ( + bbgit "github.com/craftamap/bb/git" + "github.com/kirsle/configdir" +) + +func GetGlobalConfigurationPath() (configDirectory string, filename string) { + configDirectory = configdir.LocalConfig("bb") + return configDirectory, "configuration.toml" +} + +func GetLocalConfigurationPath() (configDirectory, filename string, err error) { + configDirectory, err = bbgit.RepoPath() + return configDirectory, ".bb", err +} diff --git a/git/git.go b/git/git.go index 54a86e1..8be561d 100644 --- a/git/git.go +++ b/git/git.go @@ -62,6 +62,16 @@ func CurrentHead() (string, error) { output, err := run.PrepareCmd(headCmd).Output() return firstLine(output), err } + +func RepoPath() (string, error) { + pathCmd, err := git.GitCommand("rev-parse", "--show-toplevel") + if err != nil { + return "", err + } + output, err := run.PrepareCmd(pathCmd).Output() + return firstLine(output), err +} + func firstLine(output []byte) string { if i := bytes.IndexAny(output, "\n"); i >= 0 { return string(output)[0:i]