diff --git a/cmd/cmd.go b/cmd/cmd.go index 76eff606..29e00c01 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -82,9 +82,15 @@ func Execute() { }, { Name: "update", - Action: func(_ *cli.Context) error { - log.Print("Ipsum") - return nil + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "all", + Usage: "Update all installed plugins", + }, + }, + Action: func(cCtx *cli.Context) error { + args := cCtx.Args() + return pluginUpdateCommand(cCtx, logger, args.Get(0), args.Get(1)) }, }, }, @@ -170,3 +176,45 @@ func pluginListCommand(cCtx *cli.Context, logger *log.Logger) error { return nil } + +func pluginUpdateCommand(cCtx *cli.Context, logger *log.Logger, pluginName, ref string) error { + updateAll := cCtx.Bool("all") + if !updateAll && pluginName == "" { + return cli.Exit("usage: asdf plugin-update { [git-ref] | --all}", 1) + } + + conf, err := config.LoadConfig() + if err != nil { + logger.Printf("error loading config: %s", err) + return err + } + + if updateAll { + installedPlugins, err := plugins.List(conf, false, false) + if err != nil { + logger.Printf("failed to get plugin list: %s", err) + return err + } + + for _, plugin := range installedPlugins { + updatedToRef, err := plugins.Update(conf, plugin.Name, "") + formatUpdateResult(logger, plugin.Name, updatedToRef, err) + } + + return nil + } + + updatedToRef, err := plugins.Update(conf, pluginName, ref) + formatUpdateResult(logger, pluginName, updatedToRef, err) + return err +} + +func formatUpdateResult(logger *log.Logger, pluginName, updatedToRef string, err error) { + if err != nil { + logger.Printf("failed to update %s due to error: %s\n", pluginName, err) + + return + } + + logger.Printf("updated %s to ref %s\n", pluginName, updatedToRef) +} diff --git a/plugins/plugins.go b/plugins/plugins.go index 201db840..23359bf1 100644 --- a/plugins/plugins.go +++ b/plugins/plugins.go @@ -9,8 +9,7 @@ import ( "regexp" "asdf/config" - - "github.com/go-git/go-git/v5" + "asdf/plugins/git" ) const ( @@ -19,7 +18,9 @@ const ( pluginAlreadyExists = "plugin named %q already added" ) -// Plugin represents a plugin to the packages in asdf +// Plugin struct represents an asdf plugin to all asdf code. The name and dir +// fields are the most used fields. Ref and Dir only still git info, which is +// only information and shown to the user at times. type Plugin struct { Name string Dir string @@ -42,25 +43,22 @@ func List(config config.Config, urls, refs bool) (plugins []Plugin, err error) { var url string var refString string location := filepath.Join(pluginsDir, file.Name()) - repo, err := git.PlainOpen(location) + plugin := git.NewPlugin(location) + // TODO: Improve these error messages if err != nil { return plugins, err } if refs { - ref, err := repo.Head() - refString = ref.Hash().String() - + refString, err = plugin.Head() if err != nil { return plugins, err } } if urls { - remotes, err := repo.Remotes() - url = remotes[0].Config().URLs[0] - + url, err = plugin.RemoteURL() if err != nil { return plugins, err } @@ -107,17 +105,10 @@ func Add(config config.Config, pluginName, pluginURL string) error { return fmt.Errorf("unable to create plugin directory: %w", err) } - _, err = git.PlainClone(pluginDir, false, &git.CloneOptions{ - URL: pluginURL, - }) - if err != nil { - return fmt.Errorf("unable to clone plugin: %w", err) - } - - return nil + return git.NewPlugin(pluginDir).Clone(pluginURL) } -// Remove removes a plugin with the provided name if installed +// Remove uninstalls a plugin by removing it from the file system if installed func Remove(config config.Config, pluginName string) error { err := validatePluginName(pluginName) if err != nil { @@ -138,11 +129,33 @@ func Remove(config config.Config, pluginName string) error { return os.RemoveAll(pluginDir) } +// Update a plugin to a specific ref, or if no ref provided update to latest +func Update(config config.Config, pluginName, ref string) (string, error) { + exists, err := PluginExists(config.DataDir, pluginName) + if err != nil { + return "", fmt.Errorf("unable to check if plugin exists: %w", err) + } + + if !exists { + return "", fmt.Errorf("no such plugin: %s", pluginName) + } + + pluginDir := PluginDirectory(config.DataDir, pluginName) + + plugin := git.NewPlugin(pluginDir) + + return plugin.Update(ref) +} + // PluginExists returns a boolean indicating whether or not a plugin with the // provided name is currently installed func PluginExists(dataDir, pluginName string) (bool, error) { pluginDir := PluginDirectory(dataDir, pluginName) - fileInfo, err := os.Stat(pluginDir) + return directoryExists(pluginDir) +} + +func directoryExists(dir string) (bool, error) { + fileInfo, err := os.Stat(dir) if errors.Is(err, os.ErrNotExist) { return false, nil } @@ -154,13 +167,14 @@ func PluginExists(dataDir, pluginName string) (bool, error) { return fileInfo.IsDir(), nil } -// PluginDirectory returns the directory a plugin would be installed in, if it -// is installed +// PluginDirectory returns the directory a plugin with a given name would be in +// if it were installed func PluginDirectory(dataDir, pluginName string) string { return filepath.Join(DataDirectory(dataDir), pluginName) } -// DataDirectory return the plugin directory inside the data directory +// DataDirectory returns the path to the plugin directory inside the data +// directory func DataDirectory(dataDir string) string { return filepath.Join(dataDir, dataDirPlugins) } diff --git a/plugins/plugins_test.go b/plugins/plugins_test.go index 4d6f0583..233cf1b1 100644 --- a/plugins/plugins_test.go +++ b/plugins/plugins_test.go @@ -157,7 +157,6 @@ func TestRemove(t *testing.T) { t.Run("returns error when invalid plugin name is given", func(t *testing.T) { err := Remove(conf, "foo/bar/baz") assert.NotNil(t, err) - expectedErrMsg := "is invalid. Name may only contain lowercase letters, numbers, '_', and '-'" assert.ErrorContains(t, err, expectedErrMsg) }) @@ -173,6 +172,72 @@ func TestRemove(t *testing.T) { }) } +func TestUpdate(t *testing.T) { + testDataDir := t.TempDir() + conf := config.Config{DataDir: testDataDir} + + err := Add(conf, testPluginName, testRepo) + assert.Nil(t, err) + + badPluginName := "badplugin" + badRepo := PluginDirectory(testDataDir, badPluginName) + err = os.MkdirAll(badRepo, 0o777) + assert.Nil(t, err) + + tests := []struct { + desc string + givenConf config.Config + givenName string + givenRef string + wantSomeRef bool + wantErrMsg string + }{ + { + desc: "returns error when plugin with name does not exist", + givenConf: conf, + givenName: "nonexistant", + givenRef: "", + wantSomeRef: false, + wantErrMsg: "no such plugin: nonexistant", + }, + { + desc: "returns error when plugin repo does not exist", + givenConf: conf, + givenName: "badplugin", + givenRef: "", + wantSomeRef: false, + wantErrMsg: "unable to open plugin Git repository: repository does not exist", + }, + { + desc: "updates plugin when plugin with name exists", + givenConf: conf, + givenName: testPluginName, + givenRef: "", + wantSomeRef: true, + wantErrMsg: "", + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + updatedToRef, err := Update(tt.givenConf, tt.givenName, tt.givenRef) + + if tt.wantErrMsg == "" { + assert.Nil(t, err) + } else { + assert.NotNil(t, err) + assert.ErrorContains(t, err, tt.wantErrMsg) + } + + if tt.wantSomeRef == true { + assert.NotZero(t, updatedToRef) + } else { + assert.Zero(t, updatedToRef) + } + }) + } +} + func TestPluginExists(t *testing.T) { testDataDir := t.TempDir() pluginDir := PluginDirectory(testDataDir, testPluginName) @@ -305,29 +370,34 @@ func installMockPluginRepo(dataDir, name string) (string, error) { return location, err } - err = runCmd("git", "-C", location, "commit", "-q", "-m", fmt.Sprintf("\"asdf %s plugin\"", name)) - return location, err -} - -// helper function to make running commands easier -func runCmd(cmdName string, args ...string) error { - cmd := exec.Command(cmdName, args...) - - // Capture stdout and stderr - var stdout strings.Builder - var stderr strings.Builder - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err := cmd.Run() + err = runCmd("git", "-C", location, "commit", "-q", "-m", fmt.Sprintf("\"asdf %s plugin init\"", name)) if err != nil { - // If command fails print both stderr and stdout - fmt.Println("stdout:", stdout.String()) - fmt.Println("stderr:", stderr.String()) - return err + return location, err } - return nil + err = runCmd("touch", filepath.Join(location, "README.md")) + if err != nil { + return location, err + } + + err = runCmd("git", "-C", location, "add", "-A") + if err != nil { + return location, err + } + + err = runCmd("git", "-C", location, "commit", "-q", "-m", fmt.Sprintf("\"asdf %s plugin readme \"", name)) + if err != nil { + return location, err + } + + // kind of ugly but I want a remote with a valid path so I use the same + // location as the remote. Probably should refactor + err = runCmd("git", "-C", location, "remote", "add", "origin", location) + if err != nil { + return location, err + } + + return location, err } func moduleRoot() (string, error) { @@ -360,3 +430,24 @@ func findModuleRoot(dir string) (roots string) { } return "" } + +// helper function to make running commands easier +func runCmd(cmdName string, args ...string) error { + cmd := exec.Command(cmdName, args...) + + // Capture stdout and stderr + var stdout strings.Builder + var stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { + // If command fails print both stderr and stdout + fmt.Println("stdout:", stdout.String()) + fmt.Println("stderr:", stderr.String()) + return err + } + + return nil +}