diff --git a/check_version.go b/check_version.go index c99cb65..25bdfa1 100644 --- a/check_version.go +++ b/check_version.go @@ -36,6 +36,18 @@ func shortCommitHash(hash string) string { return hash } +func preferredRevisionCandidates(preferredRev string) []string { + preferredRev = strings.TrimSpace(preferredRev) + if preferredRev == "" { + return nil + } + candidates := []string{preferredRev} + if !strings.HasPrefix(preferredRev, "v") && semverRegexp.MatchString("v"+preferredRev) { + candidates = append(candidates, "v"+preferredRev) + } + return candidates +} + // pkgVersionFromGit determines the actual version to be packaged // from the git repository status and user preference. // Besides returning the upstream version, the "upstream" struct @@ -50,11 +62,12 @@ func pkgVersionFromGit(gitdir string, u *upstream, preferredRev string, forcePre var cmd *exec.Cmd // the temporary shell commands we execute // If the user specifies a valid tag as the preferred revision, that tag should be used without additional heuristics. - if preferredRev != "" { - cmd = exec.Command("git", "tag", "--list", preferredRev) + for _, candidate := range preferredRevisionCandidates(preferredRev) { + cmd = exec.Command("git", "tag", "--list", candidate) cmd.Dir = gitdir - if out, err := cmd.Output(); err == nil && slices.Contains(strings.Fields(string(out)), preferredRev) { - latestTag = preferredRev + if out, err := cmd.Output(); err == nil && slices.Contains(strings.Fields(string(out)), candidate) { + latestTag = candidate + break } } diff --git a/pack.go b/pack.go index d1f9494..58fb095 100644 --- a/pack.go +++ b/pack.go @@ -959,10 +959,10 @@ func mainPack(args []string, usage func()) { flagSet.Usage = usage } else { flagSet.Usage = func() { - fmt.Fprintf(os.Stderr, "Usage: %s [pack] [FLAG]... \n", os.Args[0]) - fmt.Fprintf(os.Stderr, "Example: %s pack golang.org/x/oauth2\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "Usage: %s [pack] [FLAG]... [@revision]...\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "Example: %s pack golang.org/x/oauth2@v0.30.0 github.com/pquerna/cachecontrol@v0.2.0\n", os.Args[0]) fmt.Fprintf(os.Stderr, "\n") - fmt.Fprintf(os.Stderr, "\"%s pack\" downloads the specified Go package from the Internet,\nand creates new files and directories in the current working directory.\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "\"%s pack\" downloads the specified Go packages from the Internet,\nand creates new files and directories in the current working directory.\n", os.Args[0]) fmt.Fprintf(os.Stderr, "\n") fmt.Fprintf(os.Stderr, "Flags:\n") flagSet.PrintDefaults() @@ -973,7 +973,7 @@ func mainPack(args []string, usage func()) { flagSet.StringVar(&gitRevision, "git_revision", "", - "git revision (see gitrevisions(7)) of the specified Go package\n"+ + "legacy default git revision (see gitrevisions(7)) for package arguments without @revision\n"+ "to check out, defaulting to the default behavior of git clone.\n"+ "Useful in case you do not want to package e.g. current HEAD.") @@ -1026,28 +1026,6 @@ func mainPack(args []string, usage func()) { } gitRevision = strings.TrimSpace(gitRevision) - gopkg := flagSet.Arg(0) - - // Remove URL scheme if present (https://, http://, git://, etc.) - gopkg = strings.TrimPrefix(strings.TrimPrefix(strings.TrimPrefix(gopkg, "https://"), "http://"), "git://") - - // Verify the provided argument using the official pkg.go.dev API. Keep the - // requested path for naming and go_import_path; use the module path only for - // source checkout and tarball generation. - info, err := getPkgsiteInfo(context.TODO(), gopkg) - if err != nil { - log.Fatalf("Verifying arguments: %v — did you specify a Go package import path?", err) - } - sourceGopkg := sourceImportPathForPackage(gopkg, info) - if sourceGopkg != gopkg { - log.Printf("Using module root %q as source checkout for specified import path %q", sourceGopkg, gopkg) - } - - // Set default source and binary package names. - openRuyiSrc := nameFromGopkg(gopkg, typeLibrary, customProgPkgName, allowUnknownHoster) - openRuyiLib := openRuyiSrc - openRuyiProgram := nameFromGopkg(gopkg, typeProgram, customProgPkgName, allowUnknownHoster) - var pkgType packageType switch strings.TrimSpace(pkgTypeString) { @@ -1068,6 +1046,56 @@ func mainPack(args []string, usage func()) { log.Fatalf("-type=%q not recognized, aborting\n", pkgTypeString) } + for _, arg := range flagSet.Args() { + gopkg, revision, err := parsePackArg(arg, gitRevision) + if err != nil { + log.Fatalf("parse package argument %q: %v", arg, err) + } + packPackage(gopkg, revision, forcePrerelease, pkgType, customProgPkgName, allowUnknownHoster) + } +} + +func parsePackArg(arg, defaultRevision string) (string, string, error) { + arg = strings.TrimSpace(arg) + defaultRevision = strings.TrimSpace(defaultRevision) + if arg == "" { + return "", "", fmt.Errorf("package import path is empty") + } + if at := strings.LastIndex(arg, "@"); at >= 0 { + revision := strings.TrimSpace(arg[at+1:]) + if revision == "" { + return "", "", fmt.Errorf("package revision is empty") + } + path := strings.TrimSpace(arg[:at]) + if path == "" { + return "", "", fmt.Errorf("package import path is empty") + } + return path, revision, nil + } + return arg, defaultRevision, nil +} + +func packPackage(gopkg, gitRevision string, forcePrerelease bool, pkgType packageType, customProgPkgName string, allowUnknownHoster bool) { + // Remove URL scheme if present (https://, http://, git://, etc.) + gopkg = strings.TrimPrefix(strings.TrimPrefix(strings.TrimPrefix(gopkg, "https://"), "http://"), "git://") + + // Verify the provided argument using the official pkg.go.dev API. Keep the + // requested path for naming and go_import_path; use the module path only for + // source checkout and tarball generation. + info, err := getPkgsiteInfo(context.TODO(), gopkg) + if err != nil { + log.Fatalf("Verifying arguments: %v — did you specify a Go package import path?", err) + } + sourceGopkg := sourceImportPathForPackage(gopkg, info) + if sourceGopkg != gopkg { + log.Printf("Using module root %q as source checkout for specified import path %q", sourceGopkg, gopkg) + } + + // Set default source and binary package names. + openRuyiSrc := nameFromGopkg(gopkg, typeLibrary, customProgPkgName, allowUnknownHoster) + openRuyiLib := openRuyiSrc + openRuyiProgram := nameFromGopkg(gopkg, typeProgram, customProgPkgName, allowUnknownHoster) + if pkgType != typeGuess { openRuyiSrc = nameFromGopkg(gopkg, pkgType, customProgPkgName, allowUnknownHoster) } diff --git a/pkgsite_test.go b/pkgsite_test.go index 0a75e57..a6b7f9c 100644 --- a/pkgsite_test.go +++ b/pkgsite_test.go @@ -547,6 +547,104 @@ func TestPkgVersionFromGitUsesPackagingDateAndSevenCharHash(t *testing.T) { } } +func TestPkgVersionFromGitAcceptsUnprefixedSemverPreferredRevision(t *testing.T) { + dir := t.TempDir() + runGit(t, dir, nil, "init") + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/project\n"), 0644); err != nil { + t.Fatalf("write go.mod: %v", err) + } + runGit(t, dir, nil, "add", "go.mod") + runGit(t, dir, []string{ + "GIT_AUTHOR_NAME=Test", + "GIT_AUTHOR_EMAIL=test@example.invalid", + "GIT_COMMITTER_NAME=Test", + "GIT_COMMITTER_EMAIL=test@example.invalid", + }, "commit", "-m", "initial") + runGit(t, dir, nil, "tag", "v1.2.0") + + u := upstream{} + got, err := pkgVersionFromGit(dir, &u, "1.2.0", false) + if err != nil { + t.Fatalf("pkgVersionFromGit() returned error: %v", err) + } + if got != "1.2.0" { + t.Fatalf("pkgVersionFromGit() = %q, want %q", got, "1.2.0") + } + if u.tag != "v1.2.0" || !u.isRelease { + t.Fatalf("tag = %q, isRelease = %v, want v1.2.0 release", u.tag, u.isRelease) + } +} + +func TestParsePackArg(t *testing.T) { + tests := []struct { + name string + arg string + defaultRevision string + wantPath string + wantRevision string + wantErr bool + }{ + { + name: "explicit revision", + arg: "github.com/a/pkg@v1.2.3", + wantPath: "github.com/a/pkg", + wantRevision: "v1.2.3", + }, + { + name: "explicit unprefixed semver revision", + arg: "github.com/a/pkg@1.2.3", + wantPath: "github.com/a/pkg", + wantRevision: "1.2.3", + }, + { + name: "uses default revision", + arg: "github.com/a/pkg", + defaultRevision: "v0.9.0", + wantPath: "github.com/a/pkg", + wantRevision: "v0.9.0", + }, + { + name: "keeps empty default revision", + arg: "github.com/a/pkg", + wantPath: "github.com/a/pkg", + }, + { + name: "semantic import version with explicit revision", + arg: "github.com/a/pkg/v2@v2.1.0", + wantPath: "github.com/a/pkg/v2", + wantRevision: "v2.1.0", + }, + { + name: "rejects empty explicit revision", + arg: "github.com/a/pkg@", + wantErr: true, + }, + { + name: "rejects empty import path", + arg: "@v1.2.3", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotPath, gotRevision, err := parsePackArg(tt.arg, tt.defaultRevision) + if tt.wantErr { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatalf("parsePackArg() returned error: %v", err) + } + if gotPath != tt.wantPath || gotRevision != tt.wantRevision { + t.Fatalf("parsePackArg() = (%q, %q), want (%q, %q)", gotPath, gotRevision, tt.wantPath, tt.wantRevision) + } + }) + } +} + func runGit(t *testing.T, dir string, env []string, args ...string) string { t.Helper() cmd := exec.Command("git", args...)