From da5c91d30a7e2c717e13437c65ba54149be60196 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Fri, 3 May 2024 21:42:04 -0400 Subject: [PATCH] feat(golang-rewrite): create pluginindex package * Switch to non-verbose test output in Makefile * Create pluginindex package with PluginIndex struct with methods for updating index and retrieving plugin URLs * Create IndexRepo interface to allow for easy testing out cloning real Git repository. --- Makefile | 2 +- pluginindex/pluginindex.go | 209 ++++++++++++++++++++++++++++++++ pluginindex/pluginindex_test.go | 198 ++++++++++++++++++++++++++++++ 3 files changed, 408 insertions(+), 1 deletion(-) create mode 100644 pluginindex/pluginindex.go create mode 100644 pluginindex/pluginindex_test.go diff --git a/Makefile b/Makefile index 9edc5578..03f06e39 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ tidy: audit: verify vet test test: - go test -coverprofile=/tmp/coverage.out -bench= -v -race ./... + go test -coverprofile=/tmp/coverage.out -bench= -race ./... cover: test go tool cover -html=/tmp/coverage.out diff --git a/pluginindex/pluginindex.go b/pluginindex/pluginindex.go new file mode 100644 index 00000000..13e0fc17 --- /dev/null +++ b/pluginindex/pluginindex.go @@ -0,0 +1,209 @@ +// Package pluginindex is a package that handles fetching plugin repo URLs by +// name for user convenience. +package pluginindex + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" + "gopkg.in/ini.v1" +) + +const ( + pluginIndexDir = "plugin-index" + repoUpdatedFilename = "repo-updated" +) + +type repoer interface { + Install() error + Update() error +} + +type gitRepoIndex struct { + directory string + URL string +} + +func (g *gitRepoIndex) Install() error { + opts := git.CloneOptions{ + URL: g.URL, + } + + if _, err := git.PlainClone(g.directory, false, &opts); err != nil { + return fmt.Errorf("unable to clone: %w", err) + } + + return nil +} + +func (g *gitRepoIndex) Update() error { + repo, err := git.PlainOpen(g.directory) + if err != nil { + return fmt.Errorf("unable to open plugin Git repository: %w", err) + } + + var checkoutOptions git.CheckoutOptions + + // If no ref is provided checkout latest commit on current branch + head, err := repo.Head() + if err != nil { + return fmt.Errorf("unable to get repo HEAD: %w", 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} + + fetchOptions := git.FetchOptions{RemoteName: "origin", Force: true, RefSpecs: []config.RefSpec{ + config.RefSpec(ref + ":" + ref), + }} + + if err = repo.Fetch(&fetchOptions); err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) { + return fmt.Errorf("unable to fetch from remote: %w", err) + } + + worktree, err := repo.Worktree() + if err != nil { + return fmt.Errorf("unable to open worktree: %w", err) + } + + err = worktree.Checkout(&checkoutOptions) + if err != nil { + return fmt.Errorf("unable to checkout commit: %w", err) + } + + _, err = repo.ResolveRevision(plumbing.Revision("HEAD")) + if err != nil { + return fmt.Errorf("unable to get new HEAD: %w", err) + } + return nil +} + +// PluginIndex is a struct representing the user's preferences for plugin index +// and the plugin index on disk. +type PluginIndex struct { + repo repoer + directory string + disableUpdate bool + updateDurationMinutes int +} + +// New initializes a new PluginIndex instance with the options passed in. +func New(directory string, disableUpdate bool, updateDurationMinutes int, repo repoer) PluginIndex { + return PluginIndex{ + repo: repo, + directory: directory, + disableUpdate: disableUpdate, + updateDurationMinutes: updateDurationMinutes, + } +} + +// Refresh may update the plugin repo if it hasn't been updated in longer +// than updateDurationMinutes. If the plugin repo needs to be updated the +// repo will be invoked to perform the actual Git pull. +func (p PluginIndex) Refresh() (bool, error) { + err := os.MkdirAll(p.directory, os.ModePerm) + if err != nil { + return false, err + } + + files, err := os.ReadDir(p.directory) + if err != nil { + return false, err + } + + if len(files) == 0 { + // directory empty, clone down repo + err := p.repo.Install() + if err != nil { + return false, err + } + + return touchFS(p.directory) + } + + // directory must not be empty, repo must be present, maybe update + updated, err := lastUpdated(p.directory) + if err != nil { + return p.doUpdate() + } + + // Convert minutes to nanoseconds + updateDurationNs := int64(p.updateDurationMinutes) * (6e10) + + if updated > updateDurationNs && !p.disableUpdate { + return p.doUpdate() + } + + return false, nil +} + +func (p PluginIndex) doUpdate() (bool, error) { + err := p.repo.Update() + if err != nil { + return false, err + } + + // Touch update file + return touchFS(p.directory) +} + +// GetPluginSourceURL looks up a plugin by name and returns the repository URL +// for easy install by the user. +func (p PluginIndex) GetPluginSourceURL(name string) (string, error) { + _, err := p.Refresh() + if err != nil { + return "", err + } + + url, err := readPlugin(p.directory, name) + if err != nil { + return "", err + } + + return url, nil +} + +func touchFS(directory string) (bool, error) { + filename := filepath.Join(directory, repoUpdatedFilename) + file, err := os.OpenFile(filename, os.O_RDONLY|os.O_CREATE, 0o666) + if err != nil { + return false, fmt.Errorf("unable to create file plugin index touch file: %w", err) + } + + file.Close() + return true, nil +} + +func lastUpdated(dir string) (int64, error) { + info, err := os.Stat(filepath.Join(dir, repoUpdatedFilename)) + if err != nil { + return 0, fmt.Errorf("unable to read last updated file: %w", err) + } + + // info.Atime_ns now contains the last access time + updated := time.Now().UnixNano() - info.ModTime().UnixNano() + return updated, nil +} + +func readPlugin(dir, name string) (string, error) { + filename := filepath.Join(dir, "plugins", name) + + pluginInfo, err := ini.Load(filename) + if err != nil { + return "", fmt.Errorf("no such plugin found in plugin index: %s", name) + } + + return pluginInfo.Section("").Key("repository").String(), nil +} diff --git a/pluginindex/pluginindex_test.go b/pluginindex/pluginindex_test.go new file mode 100644 index 00000000..1a499b93 --- /dev/null +++ b/pluginindex/pluginindex_test.go @@ -0,0 +1,198 @@ +package pluginindex + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +const ( + realIndexURL = "https://github.com/asdf-vm/asdf-plugins.git" + badIndexURL = "http://asdf-vm.com/non-existent" + elixirPluginURL = "https://github.com/asdf-vm/asdf-elixir.git" + erlangPluginURL = "https://github.com/asdf-vm/asdf-erlang.git" +) + +type MockIndex struct { + directory string + URL string +} + +func (m *MockIndex) Install() error { + if m.URL == badIndexURL { + return errors.New("unable to clone: repository not found") + } + + err := writeMockPluginFile(m.directory, "elixir", elixirPluginURL) + if err != nil { + return err + } + + return nil +} + +func (m *MockIndex) Update() error { + if m.URL == badIndexURL { + return errors.New("unable to clone: repository not found") + } + + // Write another plugin file to mimic update + err := writeMockPluginFile(m.directory, "erlang", erlangPluginURL) + if err != nil { + return err + } + + return nil +} + +func writeMockPluginFile(dir, pluginName, pluginURL string) error { + dirname := filepath.Join(dir, "plugins") + err := os.MkdirAll(dirname, os.ModePerm) + if err != nil { + return err + } + + filename := filepath.Join(dirname, pluginName) + file, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE, os.ModePerm) + if err != nil { + return err + } + defer file.Close() + + _, err = file.WriteString(fmt.Sprintf("repository = %s", pluginURL)) + if err != nil { + return err + } + + return nil +} + +func TestGetPluginSourceURL(t *testing.T) { + t.Run("with Git returns a plugin url when provided name of existing plugin", func(t *testing.T) { + dir := t.TempDir() + pluginIndex := New(dir, true, 0, &gitRepoIndex{directory: dir, URL: realIndexURL}) + url, err := pluginIndex.GetPluginSourceURL("elixir") + assert.Nil(t, err) + assert.Equal(t, url, elixirPluginURL) + }) + + t.Run("returns a plugin url when provided name of existing plugin", func(t *testing.T) { + dir := t.TempDir() + pluginIndex := New(dir, true, 0, &MockIndex{directory: dir, URL: realIndexURL}) + url, err := pluginIndex.GetPluginSourceURL("elixir") + assert.Nil(t, err) + assert.Equal(t, url, elixirPluginURL) + }) + + t.Run("returns a plugin url when provided name of existing plugin when loading from cache", func(t *testing.T) { + dir := t.TempDir() + pluginIndex := New(dir, false, 10, &MockIndex{directory: dir, URL: realIndexURL}) + url, err := pluginIndex.GetPluginSourceURL("elixir") + assert.Nil(t, err) + assert.Equal(t, url, elixirPluginURL) + + url, err = pluginIndex.GetPluginSourceURL("elixir") + assert.Nil(t, err) + assert.Equal(t, url, elixirPluginURL) + }) + + t.Run("returns an error when given a name that isn't in the index", func(t *testing.T) { + dir := t.TempDir() + pluginIndex := New(dir, false, 10, &MockIndex{directory: dir, URL: realIndexURL}) + url, err := pluginIndex.GetPluginSourceURL("foobar") + assert.EqualError(t, err, "no such plugin found in plugin index: foobar") + assert.Equal(t, url, "") + }) + + t.Run("returns an error when plugin index cannot be updated", func(t *testing.T) { + dir := t.TempDir() + pluginIndex := New(dir, false, 10, &MockIndex{directory: dir, URL: badIndexURL}) + + // create plain text file so it appears plugin index already exists on disk + file, err := os.OpenFile(filepath.Join(dir, "test"), os.O_RDONLY|os.O_CREATE, 0o666) + assert.Nil(t, err) + file.Close() + + url, err := pluginIndex.GetPluginSourceURL("lua") + assert.EqualError(t, err, "unable to clone: repository not found") + assert.Equal(t, url, "") + }) + + t.Run("returns error when given non-existent plugin index", func(t *testing.T) { + dir := t.TempDir() + pluginIndex := New(dir, false, 10, &MockIndex{directory: dir, URL: badIndexURL}) + url, err := pluginIndex.GetPluginSourceURL("lua") + assert.EqualError(t, err, "unable to clone: repository not found") + assert.Equal(t, url, "") + }) +} + +func TestRefresh(t *testing.T) { + t.Run("with Git updates repo when called once", func(t *testing.T) { + dir := t.TempDir() + pluginIndex := New(dir, false, 0, &gitRepoIndex{directory: dir, URL: realIndexURL}) + url, err := pluginIndex.GetPluginSourceURL("elixir") + assert.Nil(t, err) + assert.Equal(t, url, elixirPluginURL) + + updated, err := pluginIndex.Refresh() + assert.Nil(t, err) + assert.True(t, updated) + }) + + t.Run("updates repo when called once", func(t *testing.T) { + dir := t.TempDir() + pluginIndex := New(dir, false, 0, &MockIndex{directory: dir, URL: realIndexURL}) + + updated, err := pluginIndex.Refresh() + assert.Nil(t, err) + assert.True(t, updated) + + url, err := pluginIndex.GetPluginSourceURL("erlang") + assert.Nil(t, err) + assert.Equal(t, url, erlangPluginURL) + }) + + t.Run("does not update index when time has not elaspsed", func(t *testing.T) { + dir := t.TempDir() + pluginIndex := New(dir, false, 10, &MockIndex{directory: dir, URL: realIndexURL}) + + // Call Refresh twice, the second call should not perform an update + updated, err := pluginIndex.Refresh() + assert.Nil(t, err) + assert.True(t, updated) + + updated, err = pluginIndex.Refresh() + assert.Nil(t, err) + assert.False(t, updated) + }) + + t.Run("updates plugin index when time has elaspsed", func(t *testing.T) { + dir := t.TempDir() + pluginIndex := New(dir, false, 0, &MockIndex{directory: dir, URL: realIndexURL}) + + // Call Refresh twice, the second call should perform an update + updated, err := pluginIndex.Refresh() + assert.Nil(t, err) + assert.True(t, updated) + + time.Sleep(10 * time.Nanosecond) + updated, err = pluginIndex.Refresh() + assert.Nil(t, err) + assert.True(t, updated) + }) + + t.Run("returns error when plugin index repo doesn't exist", func(t *testing.T) { + dir := t.TempDir() + pluginIndex := New(dir, false, 0, &MockIndex{directory: dir, URL: badIndexURL}) + + updated, err := pluginIndex.Refresh() + assert.EqualError(t, err, "unable to clone: repository not found") + assert.False(t, updated) + }) +}