diff --git a/docs/ja-jp/manage/plugins.md b/docs/ja-jp/manage/plugins.md index 26825e1a9..ae299a81a 100644 --- a/docs/ja-jp/manage/plugins.md +++ b/docs/ja-jp/manage/plugins.md @@ -10,7 +10,16 @@ ```shell asdf plugin add -# asdf plugin add elm https://github.com/vic/asdf-elm +# asdf plugin add odo https://github.com/asdf-community/asdf-odo +``` + +また、``引数で任意のGit参照(ブランチ、タグ、またはコミット)を指定することもできます: + +```shell +asdf plugin add [] [] +# asdf plugin add odo main +# asdf plugin add odo v3.1.1 +# asdf plugin add odo https://github.com/asdf-community/asdf-odo 3ca6ab4 ``` または下記のコマンドで、プラグインリポジトリのショートネームを指定して追加します: diff --git a/docs/ko-kr/manage/plugins.md b/docs/ko-kr/manage/plugins.md index c60554f5b..d8e411b36 100644 --- a/docs/ko-kr/manage/plugins.md +++ b/docs/ko-kr/manage/plugins.md @@ -10,7 +10,16 @@ Git URL로 플러그인 추가하기: ```shell asdf plugin add -# asdf plugin add elm https://github.com/vic/asdf-elm +# asdf plugin add odo https://github.com/asdf-community/asdf-odo +``` + +`` 인수를 사용하여 임의의 Git 참조 (브랜치, 태그, 또는 커밋)를 지정할 수도 있습니다: + +```shell +asdf plugin add [] [] +# asdf plugin add odo main +# asdf plugin add odo v3.1.1 +# asdf plugin add odo https://github.com/asdf-community/asdf-odo 3ca6ab4 ``` 또는 플러그인 리포지토리에 short-name을 통해 추가하기: diff --git a/docs/manage/plugins.md b/docs/manage/plugins.md index 022436d53..ac43587c7 100644 --- a/docs/manage/plugins.md +++ b/docs/manage/plugins.md @@ -10,7 +10,16 @@ Add plugins via their Git URL: ```shell asdf plugin add -# asdf plugin add elm https://github.com/vic/asdf-elm +# asdf plugin add odo https://github.com/asdf-community/asdf-odo +``` + +You can also specify any Git reference (branch, tag, or commit) with the `` argument: + +```shell +asdf plugin add [] [] +# asdf plugin add odo main +# asdf plugin add odo v3.1.1 +# asdf plugin add odo https://github.com/asdf-community/asdf-odo 3ca6ab4 ``` or via the short-name association in the plugins repository: diff --git a/docs/pt-br/manage/plugins.md b/docs/pt-br/manage/plugins.md index 09d143961..3fafcc77a 100644 --- a/docs/pt-br/manage/plugins.md +++ b/docs/pt-br/manage/plugins.md @@ -12,7 +12,16 @@ Adicione os plugins via sua Url Git: ```shell asdf plugin add -# asdf plugin add elm https://github.com/vic/asdf-elm +# asdf plugin add odo https://github.com/asdf-community/asdf-odo +``` + +Você também pode especificar qualquer referência Git (branch, tag, ou commit) com o argumento ``: + +```shell +asdf plugin add [] [] +# asdf plugin add odo main +# asdf plugin add odo v3.1.1 +# asdf plugin add odo https://github.com/asdf-community/asdf-odo 3ca6ab4 ``` ou pelo nome abreviado dentro do repositório de plugins: diff --git a/docs/zh-hans/manage/plugins.md b/docs/zh-hans/manage/plugins.md index 86e01ae57..ab7be34fc 100644 --- a/docs/zh-hans/manage/plugins.md +++ b/docs/zh-hans/manage/plugins.md @@ -10,7 +10,16 @@ ```shell asdf plugin add -# asdf plugin add elm https://github.com/vic/asdf-elm +# asdf plugin add odo https://github.com/asdf-community/asdf-odo +``` + +您也可以使用 `` 参数来指定任何 Git 引用(分支、标签或提交): + +```shell +asdf plugin add [] [] +# asdf plugin add odo main +# asdf plugin add odo v3.1.1 +# asdf plugin add odo https://github.com/asdf-community/asdf-odo 3ca6ab4 ``` 或者通过插件存储库中的缩写添加插件: @@ -77,7 +86,7 @@ asdf plugin remove 缩写存储库将同步到你的本地计算机并定期刷新。这个周期由以下方法确定: - 命令触发的同步事件: - - `asdf plugin add ` + - `asdf plugin add ` - `asdf plugin list all` - 如果配置选项 `disable_plugin_short_name_repository` 设置为 `yes`,那么同步操作将提前终止。请查看 [asdf 配置文档](/zh-hans/manage/configuration.md) 了解更多。 - 如果在过去的 `X` 分钟内没有同步,则进行同步。 diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 7dd074017..a8f8b5ef8 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -21,6 +21,7 @@ import ( "github.com/asdf-vm/asdf/internal/exec" "github.com/asdf-vm/asdf/internal/execenv" "github.com/asdf-vm/asdf/internal/execute" + "github.com/asdf-vm/asdf/internal/git" "github.com/asdf-vm/asdf/internal/help" "github.com/asdf-vm/asdf/internal/hook" "github.com/asdf-vm/asdf/internal/info" @@ -192,7 +193,29 @@ func Execute(version string) { return err } - return pluginAddCommand(cmd, conf, logger, args.Get(0), args.Get(1)) + // Handle both formats: + // asdf plugin add + // asdf plugin add + var pluginName, pluginRepo, gitRef string + switch args.Len() { + case 1: + pluginName = args.Get(0) + case 2: + pluginName = args.Get(0) + // Could be either or + arg1 := args.Get(1) + if git.IsURL(arg1) { + pluginRepo = arg1 + } else { + gitRef = arg1 + } + case 3: + pluginName = args.Get(0) + pluginRepo = args.Get(1) + gitRef = args.Get(2) + } + + return pluginAddCommand(cmd, conf, logger, pluginName, pluginRepo, gitRef) }, }, { @@ -710,15 +733,15 @@ func anyInstalled(conf config.Config, toolVersions []toolversions.ToolVersions) return false } -func pluginAddCommand(_ *cli.Command, conf config.Config, logger *log.Logger, pluginName, pluginRepo string) error { +func pluginAddCommand(_ *cli.Command, conf config.Config, logger *log.Logger, pluginName, pluginRepo, gitRef string) error { if pluginName == "" { // Invalid arguments // Maybe one day switch this to show the generated help // cli.ShowSubcommandHelp(cCtx) - return cli.Exit("usage: asdf plugin add []", 1) + return cli.Exit("usage: asdf plugin add [] []", 1) } - err := plugins.Add(conf, pluginName, pluginRepo, "") + err := plugins.Add(conf, pluginName, pluginRepo, gitRef) if err != nil { logger.Printf("%s", err) @@ -1032,6 +1055,21 @@ func pluginTestCommand(l *log.Logger, args []string, toolVersion, ref string) { // a CLI argument if toolVersion == "" { toolVersion = allVersions[0] + } else if toolVersion == "latest" { + // Resolve "latest" to an actual version + latestVersion, err := versions.Latest(plugin, "") + if err != nil { + failTest(l, fmt.Sprintf("Unable to resolve latest version: %s", err)) + } + toolVersion = latestVersion + } else if strings.HasPrefix(toolVersion, "latest:") { + // Handle "latest:prefix" syntax + prefix := strings.TrimPrefix(toolVersion, "latest:") + latestVersion, err := versions.Latest(plugin, prefix) + if err != nil { + failTest(l, fmt.Sprintf("Unable to resolve latest version with prefix '%s': %s", prefix, err)) + } + toolVersion = latestVersion } err = versions.InstallOneVersion(conf, plugin, toolVersion, false, os.Stdout, os.Stderr) @@ -1477,6 +1515,11 @@ func whereCommand(logger *log.Logger, tool, versionStr string) error { return err } + if tool == "" { + logger.Printf("No plugin given") + return errors.New("No plugin given") + } + currentDir, err := os.Getwd() if err != nil { logger.Printf("unable to get current directory: %s", err) diff --git a/internal/git/git.go b/internal/git/git.go index 41c3fcf57..76cdaed43 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -7,6 +7,7 @@ import ( "fmt" "io/fs" "os" + "regexp" "strings" "github.com/asdf-vm/asdf/internal/execute" @@ -15,6 +16,76 @@ import ( // DefaultRemoteName for Git repositories in asdf const DefaultRemoteName = "origin" +// isSHA returns true if the ref looks like a SHA commit hash +func isSHA(ref string) bool { + // Match 7-40 hex characters (common range for git SHAs) + matched, _ := regexp.MatchString("^[0-9a-f]{7,40}$", ref) + return matched +} + +// IsURL determines if a string is a Git URL based on Git's supported protocols +// Reference: https://git-scm.com/book/en/v2/Git-on-the-Server-The-Protocols +func IsURL(s string) bool { + // Special case: "." and ".." are directory references, not git repository URLs + if s == "." || s == ".." { + return false + } + + // HTTP/HTTPS protocols - must have proper :// format + if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") { + return strings.Contains(s, "://") // Double check for proper format + } + + // Git protocol + if strings.HasPrefix(s, "git://") { + return true + } + + // SSH protocol variants + if strings.HasPrefix(s, "ssh://") { + return true + } + + // SSH shorthand (user@host:path) - must not contain spaces + if strings.Contains(s, "@") && strings.Contains(s, ":") && !strings.Contains(s, " ") { + parts := strings.Split(s, "@") + if len(parts) == 2 { + hostPath := parts[1] + // Must have : after the host part and before path + return strings.Contains(hostPath, ":") && len(strings.Split(hostPath, ":")) >= 2 + } + } + + // File protocol + if strings.HasPrefix(s, "file://") { + return true + } + + // Absolute paths are always URLs (Git branch names cannot start with /) + if strings.HasPrefix(s, "/") { + return true + } + + // Explicit relative paths are always URLs (Git branch names cannot start with ./ or ../) + if strings.HasPrefix(s, "./") || strings.HasPrefix(s, "../") { + return true + } + + // Windows-style paths are always URLs (Git branch names cannot contain \) + if len(s) >= 3 && s[1] == ':' && (s[2] == '\\' || s[2] == '/') { + return true + } + + // For anything else, check if it exists as a file or directory + // If it exists, it's more likely a file path (URL), otherwise it's likely a git ref + if _, err := os.Stat(s); err == nil { + return true + } + + // If it doesn't exist, it's more likely a git ref (branch, tag, commit) + return false +} + // Repoer 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 @@ -41,15 +112,33 @@ func NewRepo(directory string) Repo { // Clone installs a plugin via Git func (r Repo) Clone(pluginURL, ref string) error { - cmdStr := []string{"git", "clone", pluginURL, r.Directory} + var cmdStr []string - if ref != "" { - cmdStr = []string{"git", "clone", pluginURL, r.Directory, "--branch", ref} - } + if ref != "" && isSHA(ref) { + // For SHA commits, we need to clone first and then checkout the specific commit + cmdStr = []string{"git", "clone", pluginURL, r.Directory} + _, stderr, err := exec(cmdStr) + if err != nil { + return fmt.Errorf("unable to clone plugin: %s", stdErrToErrMsg(stderr)) + } - _, stderr, err := exec(cmdStr) - if err != nil { - return fmt.Errorf("unable to clone plugin: %s", stdErrToErrMsg(stderr)) + // Now checkout the specific commit + checkoutCmd := []string{"git", "-C", r.Directory, "-c", "advice.detachedHead=false", "checkout", ref} + _, stderr, err = exec(checkoutCmd) + if err != nil { + return fmt.Errorf("unable to checkout commit %s: %s", ref, stdErrToErrMsg(stderr)) + } + } else { + // For branches and tags, use --branch flag + cmdStr = []string{"git", "clone", pluginURL, r.Directory} + if ref != "" { + cmdStr = []string{"git", "clone", pluginURL, r.Directory, "--branch", ref} + } + + _, stderr, err := exec(cmdStr) + if err != nil { + return fmt.Errorf("unable to clone plugin: %s", stdErrToErrMsg(stderr)) + } } return nil @@ -113,16 +202,25 @@ func (r Repo) Update(ref string) (string, string, string, error) { commonOpts := []string{"git", "-C", r.Directory} - refSpec := fmt.Sprintf("%s:%s", shortRef, shortRef) - cmdStr := append(commonOpts, []string{"fetch", "--prune", "--update-head-ok", remoteName, refSpec}...) - - _, stderr, err := exec(cmdStr) - if err != nil { - return "", "", "", errors.New(stdErrToErrMsg(stderr)) + if isSHA(shortRef) { + // For SHA commits, fetch all refs and then checkout the specific commit + fetchCmd := append(commonOpts, []string{"fetch", "--prune", "--update-head-ok", remoteName}...) + _, stderr, err := exec(fetchCmd) + if err != nil { + return "", "", "", errors.New(stdErrToErrMsg(stderr)) + } + } else { + // For branches and tags, use refspec to create local tracking branch + refSpec := fmt.Sprintf("%s:%s", shortRef, shortRef) + fetchCmd := append(commonOpts, []string{"fetch", "--prune", "--update-head-ok", remoteName, refSpec}...) + _, stderr, err := exec(fetchCmd) + if err != nil { + return "", "", "", errors.New(stdErrToErrMsg(stderr)) + } } - cmdStr = append(commonOpts, []string{"-c", "advice.detachedHead=false", "checkout", "--force", shortRef}...) - _, stderr, err = exec(cmdStr) + cmdStr := append(commonOpts, []string{"-c", "advice.detachedHead=false", "checkout", "--force", shortRef}...) + _, stderr, err := exec(cmdStr) if err != nil { return "", "", "", errors.New(stdErrToErrMsg(stderr)) } diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 8d5b77dfa..27c007dc0 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -3,6 +3,7 @@ package git import ( "os" "path/filepath" + "strings" "testing" "github.com/asdf-vm/asdf/internal/repotest" @@ -11,6 +12,37 @@ import ( "github.com/stretchr/testify/assert" ) +func TestIsSHA(t *testing.T) { + tests := []struct { + name string + ref string + expected bool + }{ + {"Full SHA", "907ef6a8bc38f144f041e888109ed301dc3d8aaa", true}, + {"Short SHA 7 chars", "907ef6a", true}, + {"Short SHA 8 chars", "907ef6a8", true}, + {"Short SHA 12 chars", "907ef6a8bc38", true}, + {"Branch name", "main", false}, + {"Branch with numbers", "v1.2.3", false}, + {"Tag", "v1.0.0", false}, + {"Tag with numbers", "1.0.0", false}, + {"Mixed case", "907EF6A", false}, // SHA should be lowercase + {"Too short", "907ef6", false}, // Less than 7 chars + {"Too long", "907ef6a8bc38f144f041e888109ed301dc3d8aaab", false}, // More than 40 chars + {"With special chars", "907ef6a#", false}, + {"Empty string", "", false}, + {"Only numbers", "1234567", true}, // Valid hex (numbers 0-9 are valid hex) + {"Letters and numbers", "abc1234", true}, // Valid hex + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isSHA(tt.ref) + assert.Equal(t, tt.expected, result) + }) + } +} + func TestRepoClone(t *testing.T) { t.Run("when repo name is valid but URL is invalid prints an error", func(t *testing.T) { repo := NewRepo(t.TempDir()) @@ -257,3 +289,133 @@ func generateRepo(t *testing.T) string { assert.Nil(t, err) return path } + +func TestIsURL(t *testing.T) { + // Create temporary directory for testing file existence + tempDir := t.TempDir() + + // Create test files and directories + existingFile := filepath.Join(tempDir, "existing-file") + err := os.WriteFile(existingFile, []byte("test"), 0644) + assert.Nil(t, err) + + existingDir := filepath.Join(tempDir, "existing-dir") + err = os.Mkdir(existingDir, 0755) + assert.Nil(t, err) + + nestedExistingDir := filepath.Join(tempDir, "nested", "dir") + err = os.MkdirAll(nestedExistingDir, 0755) + assert.Nil(t, err) + + // Create a path that looks like a branch but exists as file + branchLikeFile := filepath.Join(tempDir, "feature", "new-ui") + err = os.MkdirAll(filepath.Dir(branchLikeFile), 0755) + assert.Nil(t, err) + err = os.WriteFile(branchLikeFile, []byte("test"), 0644) + assert.Nil(t, err) + + // Create files/dirs that look like common branch names + mainDir := filepath.Join(tempDir, "main") + err = os.Mkdir(mainDir, 0755) + assert.Nil(t, err) + + developFile := filepath.Join(tempDir, "develop") + err = os.WriteFile(developFile, []byte("test"), 0644) + assert.Nil(t, err) + + tests := []struct { + name string + input string + expected bool + }{ + // HTTP/HTTPS URLs + {"HTTP URL", "http://github.com/user/repo.git", true}, + {"HTTPS URL", "https://github.com/user/repo.git", true}, + {"HTTPS URL without .git", "https://github.com/user/repo", true}, + {"Invalid HTTP missing colon", "http//example.com", false}, // Fixed: should be false + + // Git protocol + {"Git protocol", "git://github.com/user/repo.git", true}, + + // SSH protocol variants + {"SSH full URL", "ssh://git@github.com/user/repo.git", true}, + {"SSH shorthand", "git@github.com:user/repo.git", true}, + {"SSH shorthand without .git", "git@github.com:user/repo", true}, + {"SSH with different user", "user@example.com:project/repo.git", true}, + {"SSH-like but with spaces", "git@github.com: user/repo", false}, // Fixed: should be false + + // File protocol + {"File protocol", "file:///path/to/repo", true}, + + // Local file paths - absolute paths (always URLs regardless of existence) + {"Absolute path", "/home/user/repo", true}, + {"Absolute path with spaces", "/home/user/my repo", true}, + {"Root path", "/", true}, + + // Explicit relative paths (always URLs regardless of existence) + {"Relative path with ./", "./repo", true}, + {"Parent directory with ../", "../repo", true}, + {"Nested relative path with ./", "./projects/myrepo", true}, + {"Parent then child with ../", "../projects/myrepo", true}, + + // Windows paths (always URLs) + {"Windows absolute path", "C:\\Users\\repo", true}, + {"Windows absolute path forward slash", "C:/Users/repo", true}, + {"Windows different drive", "D:\\projects\\repo", true}, + + // File existence tests - any name could be a file or git ref + {"Existing file with slash", branchLikeFile, true}, // Should be URL because file exists + {"Existing directory", nestedExistingDir, true}, // Should be URL because dir exists + {"Non-existing path with slash", "nonexistent/path", false}, // Should be branch because doesn't exist (relative path) + {"Branch-like name that exists as dir", mainDir, true}, // Should be URL because dir exists + {"Branch-like name that exists as file", developFile, true}, // Should be URL because file exists + + // Git references that don't exist as files (should return false) + {"Branch name main", "main", false}, // Common branch name, doesn't exist as file + {"Branch name develop", "develop", false}, // Common branch name, doesn't exist as file + {"Branch name with dash", "feature-branch", false}, + {"Branch name with underscore", "feature_branch", false}, + {"Branch name with slash", "feature/new-ui", false}, // Common git branch pattern - doesn't exist as file + {"Branch name with slash and numbers", "releases/v1.0", false}, + {"Nested branch name", "hotfix/urgent/security-fix", false}, + {"Tag version", "v1.2.3", false}, + {"Tag without v", "1.0.0", false}, + {"SHA commit", "907ef6a8bc38f144f041e888109ed301dc3d8aaa", false}, + {"Short SHA", "907ef6a", false}, + {"Branch with numbers", "release-2023", false}, + {"Simple word", "production", false}, + + // Edge cases + {"Empty string", "", false}, + {"Just @", "@", false}, + {"Just :", ":", false}, + {"Just /", "/", true}, // Absolute path + {"Email address", "user@example.com", false}, // Has @ but no : after @ + {"Version tag starting with v", "v2.1.0", false}, + {"Domain-like", "example.com", false}, // More likely a git ref + + // SSH edge cases + {"SSH with port", "git@github.com:22:user/repo.git", true}, + {"SSH-like missing colon after @", "git@github.com", false}, + {"SSH-like with spaces", "git@host: path with spaces", false}, + + // Path-like edge cases + {"Relative path no dots", "projects/myrepo", false}, // Should be branch if doesn't exist + {"Path starting with v", "v1.0/something", false}, // Excluded from relative path logic + {"Just a dot", ".", false}, + {"Just two dots", "..", false}, + {"Three dots", "...", false}, + + // Boundary cases + {"Very long path", strings.Repeat("a", 200) + "/path", false}, // Long branch name + {"Path with special chars", "feature/issue-#123", false}, // Branch with special chars + {"Unicode in path", "功能/新界面", false}, // Unicode branch name + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsURL(tt.input) + assert.Equal(t, tt.expected, result, "Input: %s", tt.input) + }) + } +} diff --git a/internal/help/help.txt b/internal/help/help.txt index e4b3b86b0..c075f924a 100644 --- a/internal/help/help.txt +++ b/internal/help/help.txt @@ -1,7 +1,9 @@ MANAGE PLUGINS -asdf plugin add [] Add a plugin from the plugin repo OR, +asdf plugin add [] [] + Add a plugin from the plugin repo OR, add a Git repo as a plugin by - specifying the name and repo url + specifying the name and repo url. Will + use default branch or particular git-ref asdf plugin list [--urls] [--refs] List installed plugins. Optionally show git urls and git-ref asdf plugin list all List plugins registered on asdf-plugins diff --git a/internal/installs/installs.go b/internal/installs/installs.go index 493fcb6b6..48406e2f2 100644 --- a/internal/installs/installs.go +++ b/internal/installs/installs.go @@ -27,7 +27,20 @@ func Installed(conf config.Config, plugin plugins.Plugin) (versions []string, er } for _, file := range files { - if !file.IsDir() { + // Check if it's a directory OR a symlink to a directory + isVersion := false + if file.IsDir() { + isVersion = true + } else if file.Type()&fs.ModeSymlink != 0 { + // Follow the symlink to see if it points to a directory + location := filepath.Join(installDirectory, file.Name()) + info, err := os.Stat(location) + if err == nil && info.IsDir() { + isVersion = true + } + } + + if !isVersion { continue } diff --git a/internal/installs/installs_test.go b/internal/installs/installs_test.go index f8e7f22c4..b40491e26 100644 --- a/internal/installs/installs_test.go +++ b/internal/installs/installs_test.go @@ -63,6 +63,29 @@ func TestInstalled(t *testing.T) { assert.Nil(t, err) assert.Equal(t, installedVersions, []string{"1.0.0"}) }) + + t.Run("returns symlinked version directories", func(t *testing.T) { + mockInstall(t, conf, plugin, "2.0.0") + + // Create a real version directory elsewhere + realVersionDir := filepath.Join(t.TempDir(), "real_version") + err := os.MkdirAll(realVersionDir, 0o755) + assert.Nil(t, err) + + // Create a symlink to it in the installs directory + installsDir := InstallPath(conf, plugin, toolversions.Version{Type: "version", Value: "dummy"}) + installsDir = filepath.Dir(installsDir) // Get the parent directory (removes /dummy) + symlinkPath := filepath.Join(installsDir, "3.0.0") + err = os.Symlink(realVersionDir, symlinkPath) + assert.Nil(t, err) + + installedVersions, err := Installed(conf, plugin) + assert.Nil(t, err) + + // Should find both regular and symlinked versions + assert.Contains(t, installedVersions, "2.0.0") + assert.Contains(t, installedVersions, "3.0.0") + }) } func TestIsInstalled(t *testing.T) { diff --git a/internal/plugins/plugins.go b/internal/plugins/plugins.go index faf8f0f6f..f5a11d3ab 100644 --- a/internal/plugins/plugins.go +++ b/internal/plugins/plugins.go @@ -312,11 +312,24 @@ func List(config config.Config, urls, refs bool) (plugins []Plugin, err error) { } for _, file := range files { + location := filepath.Join(pluginsDir, file.Name()) + + // Check if it's a directory OR a symlink to a directory + isPlugin := false if file.IsDir() { + isPlugin = true + } else if file.Type()&fs.ModeSymlink != 0 { + // Follow the symlink to see if it points to a directory + info, err := os.Stat(location) + if err == nil && info.IsDir() { + isPlugin = true + } + } + + if isPlugin { if refs || urls { var url string var refString string - location := filepath.Join(pluginsDir, file.Name()) repo := git.NewRepo(location) // TODO: Improve these error messages @@ -347,7 +360,7 @@ func List(config config.Config, urls, refs bool) (plugins []Plugin, err error) { } else { plugins = append(plugins, Plugin{ Name: file.Name(), - Dir: filepath.Join(pluginsDir, file.Name()), + Dir: location, }) } } diff --git a/internal/plugins/plugins_test.go b/internal/plugins/plugins_test.go index e17035ceb..23bccfb77 100644 --- a/internal/plugins/plugins_test.go +++ b/internal/plugins/plugins_test.go @@ -67,6 +67,31 @@ func TestList(t *testing.T) { assert.NotZero(t, plugin.Ref) assert.NotZero(t, plugin.URL) }) + + t.Run("returns symlinked plugin directories", func(t *testing.T) { + // Create a real plugin directory in a different location + realPluginDir := filepath.Join(testDataDir, "real_plugin_location") + err := os.MkdirAll(realPluginDir, 0o755) + assert.Nil(t, err) + + // Create a symlink to it in the plugins directory + pluginsDir := filepath.Join(testDataDir, "plugins") + symlinkPath := filepath.Join(pluginsDir, "symlinked-plugin") + err = os.Symlink(realPluginDir, symlinkPath) + assert.Nil(t, err) + + plugins, err := List(conf, false, false) + assert.Nil(t, err) + + // Should find both the regular plugin and the symlinked one + pluginNames := []string{} + for _, plugin := range plugins { + pluginNames = append(pluginNames, plugin.Name) + } + + assert.Contains(t, pluginNames, "lua") + assert.Contains(t, pluginNames, "symlinked-plugin") + }) } func TestNew(t *testing.T) {