mirror of
https://github.com/asdf-vm/asdf.git
synced 2024-12-23 20:05:09 -07:00
Merge pull request #40 from asdf-vm/tb/plugin-repository
feat(golang-rewrite): create pluginindex package
This commit is contained in:
commit
89ec687da9
2
Makefile
2
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
|
||||
|
209
pluginindex/pluginindex.go
Normal file
209
pluginindex/pluginindex.go
Normal file
@ -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
|
||||
}
|
198
pluginindex/pluginindex_test.go
Normal file
198
pluginindex/pluginindex_test.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
Loading…
Reference in New Issue
Block a user