diff --git a/cmd/core/pkgcmd/cmd_install.go b/cmd/core/pkgcmd/cmd_install.go index 4d0ab9c..024ae89 100644 --- a/cmd/core/pkgcmd/cmd_install.go +++ b/cmd/core/pkgcmd/cmd_install.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "os/exec" "path/filepath" "strings" @@ -22,10 +23,12 @@ var ( installAddToReg bool ) +var errInvalidPkgInstallSource = errors.New("invalid repo format: use org/repo or org/repo@ref") + // addPkgInstallCommand adds the 'pkg install' command. func addPkgInstallCommand(parent *cobra.Command) { installCmd := &cobra.Command{ - Use: "install [org/]repo", + Use: "install [org/]repo[@ref]", Short: i18n.T("cmd.pkg.install.short"), Long: i18n.T("cmd.pkg.install.long"), RunE: func(cmd *cobra.Command, args []string) error { @@ -48,14 +51,9 @@ func runPkgInstall(repoArg, targetDir string, addToRegistry bool) error { // Parse repo shorthand: // - repoName -> defaults to host-uk/repoName // - org/repo -> uses the explicit org - org := "host-uk" - repoName := repoArg - if strings.Contains(repoArg, "/") { - parts := strings.Split(repoArg, "/") - if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - return errors.New(i18n.T("cmd.pkg.error.invalid_repo_format")) - } - org, repoName = parts[0], parts[1] + org, repoName, ref, err := parsePkgInstallSource(repoArg) + if err != nil { + return err } // Determine target directory @@ -93,11 +91,18 @@ func runPkgInstall(repoArg, targetDir string, addToRegistry bool) error { } fmt.Printf("%s %s/%s\n", dimStyle.Render(i18n.T("cmd.pkg.install.installing_label")), org, repoName) + if ref != "" { + fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("ref")), ref) + } fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("target")), repoPath) fmt.Println() fmt.Printf(" %s... ", dimStyle.Render(i18n.T("common.status.cloning"))) - err := gitClone(ctx, org, repoName, repoPath) + if ref == "" { + err = gitClone(ctx, org, repoName, repoPath) + } else { + err = gitCloneRef(ctx, org, repoName, repoPath, ref) + } if err != nil { fmt.Printf("%s\n", errorStyle.Render("✗ "+err.Error())) return err @@ -118,6 +123,36 @@ func runPkgInstall(repoArg, targetDir string, addToRegistry bool) error { return nil } +func parsePkgInstallSource(repoArg string) (org, repoName, ref string, err error) { + org = "host-uk" + repoName = strings.TrimSpace(repoArg) + if repoName == "" { + return "", "", "", errors.New("repository argument required") + } + + if at := strings.LastIndex(repoName, "@"); at >= 0 { + ref = strings.TrimSpace(repoName[at+1:]) + repoName = strings.TrimSpace(repoName[:at]) + if ref == "" || repoName == "" { + return "", "", "", errInvalidPkgInstallSource + } + } + + if strings.Contains(repoName, "/") { + parts := strings.Split(repoName, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", "", errInvalidPkgInstallSource + } + org, repoName = parts[0], parts[1] + } + + if strings.Contains(repoName, "/") { + return "", "", "", errInvalidPkgInstallSource + } + + return org, repoName, ref, nil +} + func addToRegistryFile(org, repoName string) error { regPath, err := repos.FindRegistry(coreio.Local) if err != nil { @@ -146,6 +181,30 @@ func addToRegistryFile(org, repoName string) error { return coreio.Local.Write(regPath, content) } +func clonePackageAtRef(ctx context.Context, org, repo, path, ref string) error { + if ghAuthenticated() { + httpsURL := fmt.Sprintf("https://github.com/%s/%s.git", org, repo) + args := []string{"repo", "clone", httpsURL, path, "--", "--branch", ref, "--single-branch"} + cmd := exec.CommandContext(ctx, "gh", args...) + output, err := cmd.CombinedOutput() + if err == nil { + return nil + } + errStr := strings.TrimSpace(string(output)) + if strings.Contains(errStr, "already exists") { + return errors.New(errStr) + } + } + + args := []string{"clone", "--branch", ref, "--single-branch", fmt.Sprintf("git@github.com:%s/%s.git", org, repo), path} + cmd := exec.CommandContext(ctx, "git", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return errors.New(strings.TrimSpace(string(output))) + } + return nil +} + func detectRepoType(name string) string { lower := strings.ToLower(name) if strings.Contains(lower, "-mod-") || strings.HasSuffix(lower, "-mod") { diff --git a/cmd/core/pkgcmd/cmd_install_test.go b/cmd/core/pkgcmd/cmd_install_test.go index 5d0f75d..8691a8d 100644 --- a/cmd/core/pkgcmd/cmd_install_test.go +++ b/cmd/core/pkgcmd/cmd_install_test.go @@ -67,3 +67,48 @@ func TestRunPkgInstall_InvalidRepoFormat_Bad(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "invalid repo format") } + +func TestParsePkgInstallSource_Good(t *testing.T) { + t.Run("default org and repo", func(t *testing.T) { + org, repo, ref, err := parsePkgInstallSource("core-api") + require.NoError(t, err) + assert.Equal(t, "host-uk", org) + assert.Equal(t, "core-api", repo) + assert.Empty(t, ref) + }) + + t.Run("explicit org and ref", func(t *testing.T) { + org, repo, ref, err := parsePkgInstallSource("myorg/core-api@v1.2.3") + require.NoError(t, err) + assert.Equal(t, "myorg", org) + assert.Equal(t, "core-api", repo) + assert.Equal(t, "v1.2.3", ref) + }) +} + +func TestRunPkgInstall_WithRef_UsesRefClone_Good(t *testing.T) { + tmp := t.TempDir() + targetDir := filepath.Join(tmp, "packages") + + originalGitCloneRef := gitCloneRef + t.Cleanup(func() { + gitCloneRef = originalGitCloneRef + }) + + var gotOrg, gotRepo, gotPath, gotRef string + gitCloneRef = func(_ context.Context, org, repoName, repoPath, ref string) error { + gotOrg = org + gotRepo = repoName + gotPath = repoPath + gotRef = ref + return nil + } + + err := runPkgInstall("myorg/core-api@v1.2.3", targetDir, false) + require.NoError(t, err) + + assert.Equal(t, "myorg", gotOrg) + assert.Equal(t, "core-api", gotRepo) + assert.Equal(t, filepath.Join(targetDir, "core-api"), gotPath) + assert.Equal(t, "v1.2.3", gotRef) +} diff --git a/cmd/core/pkgcmd/cmd_pkg.go b/cmd/core/pkgcmd/cmd_pkg.go index ca364f6..f1a5e7e 100644 --- a/cmd/core/pkgcmd/cmd_pkg.go +++ b/cmd/core/pkgcmd/cmd_pkg.go @@ -15,6 +15,7 @@ var ( dimStyle = cli.DimStyle ghAuthenticated = cli.GhAuthenticated gitClone = cli.GitClone + gitCloneRef = clonePackageAtRef ) // AddPkgCommands adds the 'pkg' command and subcommands for package management. diff --git a/pkg/cli/utils.go b/pkg/cli/utils.go index 8a33b27..a45e6d3 100644 --- a/pkg/cli/utils.go +++ b/pkg/cli/utils.go @@ -486,9 +486,19 @@ func ChooseMultiAction[T any](verb, subject string, items []T, opts ...ChooseOpt // GitClone clones a GitHub repository to the specified path. // Prefers 'gh repo clone' if authenticated, falls back to SSH. func GitClone(ctx context.Context, org, repo, path string) error { + return GitCloneRef(ctx, org, repo, path, "") +} + +// GitCloneRef clones a GitHub repository at a specific ref to the specified path. +// Prefers 'gh repo clone' if authenticated, falls back to SSH. +func GitCloneRef(ctx context.Context, org, repo, path, ref string) error { if GhAuthenticated() { httpsURL := fmt.Sprintf("https://github.com/%s/%s.git", org, repo) - cmd := exec.CommandContext(ctx, "gh", "repo", "clone", httpsURL, path) + args := []string{"repo", "clone", httpsURL, path} + if ref != "" { + args = append(args, "--", "--branch", ref, "--single-branch") + } + cmd := exec.CommandContext(ctx, "gh", args...) output, err := cmd.CombinedOutput() if err == nil { return nil @@ -499,7 +509,12 @@ func GitClone(ctx context.Context, org, repo, path string) error { } } // Fall back to SSH clone - cmd := exec.CommandContext(ctx, "git", "clone", fmt.Sprintf("git@github.com:%s/%s.git", org, repo), path) + args := []string{"clone"} + if ref != "" { + args = append(args, "--branch", ref, "--single-branch") + } + args = append(args, fmt.Sprintf("git@github.com:%s/%s.git", org, repo), path) + cmd := exec.CommandContext(ctx, "git", args...) output, err := cmd.CombinedOutput() if err != nil { return errors.New(strings.TrimSpace(string(output)))