From 325cd3334b3898cbd084ac3b3686458cc92b613e Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sat, 27 Apr 2024 15:54:30 -0400 Subject: [PATCH] feat(golang-rewrite): create plugins/git package to store plugin Git operations --- plugins/git/git.go | 147 ++++++++++++++++++++++++++++ plugins/git/git_test.go | 206 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 353 insertions(+) create mode 100644 plugins/git/git.go create mode 100644 plugins/git/git_test.go diff --git a/plugins/git/git.go b/plugins/git/git.go new file mode 100644 index 00000000..a74101a3 --- /dev/null +++ b/plugins/git/git.go @@ -0,0 +1,147 @@ +// Package git contains all the Git operations that can be applied to asdf +// plugins +package git + +import ( + "fmt" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" +) + +const remoteName = "origin" + +// Plugin is a struct to contain the plugin Git details +type Plugin struct { + directory string +} + +// PluginOps is an interface for operations that can be applied to asdf plugins. +// Right now we only support Git, but in the future we might have other +// mechanisms to install and upgrade plugins. asdf doesn't require a plugin +// to be a Git repository when asdf uses it, but Git is the only way to install +// and upgrade plugins. If other approaches are supported this will be +// extracted into the `plugins` module. +type PluginOps interface { + Clone(pluginURL string) error + Head() (string, error) + RemoteURL() (string, error) + Update(ref string) (string, error) +} + +// NewPlugin builds a new Plugin instance +func NewPlugin(directory string) Plugin { + return Plugin{directory: directory} +} + +// Clone installs a plugin via Git +func (g Plugin) Clone(pluginURL string) error { + _, err := git.PlainClone(g.directory, false, &git.CloneOptions{ + URL: pluginURL, + }) + + if err != nil { + return fmt.Errorf("unable to clone plugin: %w", err) + } + + return nil +} + +// Head returns the current HEAD ref of the plugin's Git repository +func (g Plugin) Head() (string, error) { + repo, err := gitOpen(g.directory) + + if err != nil { + return "", err + } + + ref, err := repo.Head() + if err != nil { + return "", err + } + + return ref.Hash().String(), nil +} + +// RemoteURL returns the URL of the default remote for the plugin's Git repository +func (g Plugin) RemoteURL() (string, error) { + repo, err := gitOpen(g.directory) + + if err != nil { + return "", err + } + + remotes, err := repo.Remotes() + if err != nil { + return "", err + } + + return remotes[0].Config().URLs[0], nil +} + +// Update updates the plugin's Git repository to the ref if provided, or the +// latest commit on the current branch +func (g Plugin) Update(ref string) (string, error) { + repo, err := gitOpen(g.directory) + + if err != nil { + return "", err + } + + var checkoutOptions git.CheckoutOptions + + if ref == "" { + // If no ref is provided checkout latest commit on current branch + head, err := repo.Head() + + if err != nil { + return "", err + } + + if !head.Name().IsBranch() { + return "", fmt.Errorf("not on a branch, unable to update") + } + + // If on a branch checkout the latest version of it from the remote + branch := head.Name() + ref = branch.String() + checkoutOptions = git.CheckoutOptions{Branch: branch, Force: true} + } else { + // Checkout ref if provided + checkoutOptions = git.CheckoutOptions{Hash: plumbing.NewHash(ref), Force: true} + } + + fetchOptions := git.FetchOptions{RemoteName: remoteName, Force: true, RefSpecs: []config.RefSpec{ + config.RefSpec(ref + ":" + ref), + }} + + err = repo.Fetch(&fetchOptions) + + if err != nil && err != git.NoErrAlreadyUpToDate { + return "", err + } + + worktree, err := repo.Worktree() + if err != nil { + return "", err + } + + err = worktree.Checkout(&checkoutOptions) + if err != nil { + return "", err + } + + hash, err := repo.ResolveRevision(plumbing.Revision("HEAD")) + return hash.String(), err +} + +func gitOpen(directory string) (*git.Repository, error) { + repo, err := git.PlainOpen(directory) + + if err != nil { + return repo, fmt.Errorf("unable to open plugin Git repository: %w", err) + } + + return repo, nil +} diff --git a/plugins/git/git_test.go b/plugins/git/git_test.go new file mode 100644 index 00000000..6744f0a7 --- /dev/null +++ b/plugins/git/git_test.go @@ -0,0 +1,206 @@ +package git + +import ( + "os" + "path/filepath" + "testing" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/stretchr/testify/assert" +) + +// TODO: Switch to local repo so tests don't go over the network +const ( + testRepo = "https://github.com/Stratus3D/asdf-lua" + testPluginName = "lua" +) + +func TestPluginClone(t *testing.T) { + t.Run("when plugin name is valid but URL is invalid prints an error", func(t *testing.T) { + tempDir := t.TempDir() + directory := filepath.Join(tempDir, testPluginName) + + plugin := NewPlugin(directory) + err := plugin.Clone("foobar") + + assert.ErrorContains(t, err, "unable to clone plugin: repository not found") + }) + + t.Run("clones provided Git URL to plugin directory when URL is valid", func(t *testing.T) { + tempDir := t.TempDir() + directory := filepath.Join(tempDir, testPluginName) + + plugin := NewPlugin(directory) + err := plugin.Clone(testRepo) + + assert.Nil(t, err) + + // Assert plugin directory contains Git repo with bin directory + _, err = os.ReadDir(directory + "/.git") + assert.Nil(t, err) + + entries, err := os.ReadDir(directory + "/bin") + assert.Nil(t, err) + assert.Equal(t, 5, len(entries)) + }) +} + +func TestPluginHead(t *testing.T) { + tempDir := t.TempDir() + directory := filepath.Join(tempDir, testPluginName) + + plugin := NewPlugin(directory) + + err := plugin.Clone(testRepo) + assert.Nil(t, err) + + head, err := plugin.Head() + assert.Nil(t, err) + assert.NotZero(t, head) +} + +func TestPluginRemoteURL(t *testing.T) { + tempDir := t.TempDir() + directory := filepath.Join(tempDir, testPluginName) + + plugin := NewPlugin(directory) + + err := plugin.Clone(testRepo) + assert.Nil(t, err) + + url, err := plugin.RemoteURL() + assert.Nil(t, err) + assert.NotZero(t, url) +} + +func TestPluginUpdate(t *testing.T) { + tempDir := t.TempDir() + directory := filepath.Join(tempDir, testPluginName) + + plugin := NewPlugin(directory) + + err := plugin.Clone(testRepo) + assert.Nil(t, err) + + t.Run("returns error when plugin with name does not exist", func(t *testing.T) { + nonexistantPath := filepath.Join(tempDir, "nonexistant") + nonexistantPlugin := NewPlugin(nonexistantPath) + updatedToRef, err := nonexistantPlugin.Update("") + + assert.NotNil(t, err) + assert.Equal(t, updatedToRef, "") + expectedErrMsg := "unable to open plugin Git repository: repository does not exist" + assert.ErrorContains(t, err, expectedErrMsg) + }) + + t.Run("returns error when plugin repo does not exist", func(t *testing.T) { + badPluginName := "badplugin" + badPluginDir := filepath.Join(tempDir, badPluginName) + err := os.MkdirAll(badPluginDir, 0777) + assert.Nil(t, err) + + badPlugin := NewPlugin(badPluginDir) + + updatedToRef, err := badPlugin.Update("") + + assert.NotNil(t, err) + assert.Equal(t, updatedToRef, "") + expectedErrMsg := "unable to open plugin Git repository: repository does not exist" + assert.ErrorContains(t, err, expectedErrMsg) + }) + + t.Run("does not return error when plugin is already updated", func(t *testing.T) { + // update plugin twice to test already updated case + updatedToRef, err := plugin.Update("") + assert.Nil(t, err) + updatedToRef2, err := plugin.Update("") + assert.Nil(t, err) + assert.Equal(t, updatedToRef, updatedToRef2) + }) + + t.Run("updates plugin when plugin when plugin exists", func(t *testing.T) { + latestHash, err := getCurrentCommit(directory) + assert.Nil(t, err) + + _, err = checkoutPreviousCommit(directory) + assert.Nil(t, err) + + updatedToRef, err := plugin.Update("") + assert.Nil(t, err) + assert.Equal(t, latestHash, updatedToRef) + + currentHash, err := getCurrentCommit(directory) + assert.Nil(t, err) + assert.Equal(t, latestHash, currentHash) + }) + + t.Run("Returns error when specified ref does not exist", func(t *testing.T) { + ref := "non-existant" + updatedToRef, err := plugin.Update(ref) + assert.Equal(t, updatedToRef, "") + expectedErrMsg := "couldn't find remote ref \"non-existant\"" + assert.ErrorContains(t, err, expectedErrMsg) + + }) + + t.Run("updates plugin to ref when plugin with name and ref exist", func(t *testing.T) { + ref := "master" + + hash, err := getCommit(directory, ref) + assert.Nil(t, err) + + updatedToRef, err := plugin.Update(ref) + assert.Nil(t, err) + assert.Equal(t, hash, updatedToRef) + + // Check that plugin was updated to ref + latestHash, err := getCurrentCommit(directory) + assert.Nil(t, err) + assert.Equal(t, hash, latestHash) + }) +} + +func getCurrentCommit(path string) (string, error) { + return getCommit(path, "HEAD") +} + +func getCommit(path, revision string) (string, error) { + repo, err := git.PlainOpen(path) + + if err != nil { + return "", err + } + + hash, err := repo.ResolveRevision(plumbing.Revision(revision)) + + return hash.String(), err +} + +func checkoutPreviousCommit(path string) (string, error) { + repo, err := git.PlainOpen(path) + + if err != nil { + return "", err + } + + previousHash, err := repo.ResolveRevision(plumbing.Revision("HEAD~")) + + if err != nil { + return "", err + } + + worktree, err := repo.Worktree() + + if err != nil { + return "", err + } + + err = worktree.Reset(&git.ResetOptions{Commit: *previousHash}) + + if err != nil { + return "", err + } + + return previousHash.String(), nil +}