cmd/go/internal/modfetch: do not short-circuit canonical versions

Since at least CL 121857, the conversion logic in
(*modfetch).codeRepo.Stat has had a short-circuit to use the version
requested by the caller if it successfully resolves and is already
canonical.

However, we should not use that version if it refers to a branch
instead of a tag, because branches (unlike tags) usually do not refer
to a single, stable release: a branch named "v1.0.0" may be for the
development of the v1.0.0 release, or for the development of patches
based on v1.0.0, but only one commit (perhaps at the end of that
branch — but possibly not even written yet!) can be that specific
version.

We already have some logic to prefer tags that are semver-equivalent
to the version requested by the caller. That more general case
suffices for exact equality too — so we can eliminate the
special-case, fixing the bug and (happily!) also somewhat simplifying
the code.

Fixes #35671
Updates #41512

Change-Id: I2fd290190b8a99a580deec7e26d15659b58a50b0
Reviewed-on: https://go-review.googlesource.com/c/go/+/378400
Trust: Bryan Mills <bcmills@google.com>
Run-TryBot: Bryan Mills <bcmills@google.com>
Reviewed-by: Russ Cox <rsc@golang.org>
TryBot-Result: Gopher Robot <gobot@golang.org>
diff --git a/src/cmd/go/internal/modfetch/coderepo.go b/src/cmd/go/internal/modfetch/coderepo.go
index 79da010..2206c7c 100644
--- a/src/cmd/go/internal/modfetch/coderepo.go
+++ b/src/cmd/go/internal/modfetch/coderepo.go
@@ -298,16 +298,13 @@
 // If statVers is a valid module version, it is used for the Version field.
 // Otherwise, the Version is derived from the passed-in info and recent tags.
 func (r *codeRepo) convert(info *codehost.RevInfo, statVers string) (*RevInfo, error) {
-	info2 := &RevInfo{
-		Name:  info.Name,
-		Short: info.Short,
-		Time:  info.Time,
-	}
-
 	// If this is a plain tag (no dir/ prefix)
 	// and the module path is unversioned,
 	// and if the underlying file tree has no go.mod,
 	// then allow using the tag with a +incompatible suffix.
+	//
+	// (If the version is +incompatible, then the go.mod file must not exist:
+	// +incompatible is not an ongoing opt-out from semantic import versioning.)
 	var canUseIncompatible func() bool
 	canUseIncompatible = func() bool {
 		var ok bool
@@ -321,19 +318,12 @@
 		return ok
 	}
 
-	invalidf := func(format string, args ...any) error {
-		return &module.ModuleError{
-			Path: r.modPath,
-			Err: &module.InvalidVersionError{
-				Version: info2.Version,
-				Err:     fmt.Errorf(format, args...),
-			},
-		}
-	}
-
-	// checkGoMod verifies that the go.mod file for the module exists or does not
-	// exist as required by info2.Version and the module path represented by r.
-	checkGoMod := func() (*RevInfo, error) {
+	// checkCanonical verifies that the canonical version v is compatible with the
+	// module path represented by r, adding a "+incompatible" suffix if needed.
+	//
+	// If statVers is also canonical, checkCanonical also verifies that v is
+	// either statVers or statVers with the added "+incompatible" suffix.
+	checkCanonical := func(v string) (*RevInfo, error) {
 		// If r.codeDir is non-empty, then the go.mod file must exist: the module
 		// author — not the module consumer, — gets to decide how to carve up the repo
 		// into modules.
@@ -344,73 +334,91 @@
 		// r.findDir verifies both of these conditions. Execute it now so that
 		// r.Stat will correctly return a notExistError if the go.mod location or
 		// declared module path doesn't match.
-		_, _, _, err := r.findDir(info2.Version)
+		_, _, _, err := r.findDir(v)
 		if err != nil {
 			// TODO: It would be nice to return an error like "not a module".
 			// Right now we return "missing go.mod", which is a little confusing.
 			return nil, &module.ModuleError{
 				Path: r.modPath,
 				Err: &module.InvalidVersionError{
-					Version: info2.Version,
+					Version: v,
 					Err:     notExistError{err: err},
 				},
 			}
 		}
 
-		// If the version is +incompatible, then the go.mod file must not exist:
-		// +incompatible is not an ongoing opt-out from semantic import versioning.
-		if strings.HasSuffix(info2.Version, "+incompatible") {
-			if !canUseIncompatible() {
-				if r.pathMajor != "" {
-					return nil, invalidf("+incompatible suffix not allowed: module path includes a major version suffix, so major version must match")
-				} else {
-					return nil, invalidf("+incompatible suffix not allowed: module contains a go.mod file, so semantic import versioning is required")
-				}
-			}
-
-			if err := module.CheckPathMajor(strings.TrimSuffix(info2.Version, "+incompatible"), r.pathMajor); err == nil {
-				return nil, invalidf("+incompatible suffix not allowed: major version %s is compatible", semver.Major(info2.Version))
+		invalidf := func(format string, args ...any) error {
+			return &module.ModuleError{
+				Path: r.modPath,
+				Err: &module.InvalidVersionError{
+					Version: v,
+					Err:     fmt.Errorf(format, args...),
+				},
 			}
 		}
 
-		return info2, nil
+		// Add the +incompatible suffix if needed or requested explicitly, and
+		// verify that its presence or absence is appropriate for this version
+		// (which depends on whether it has an explicit go.mod file).
+
+		if v == strings.TrimSuffix(statVers, "+incompatible") {
+			v = statVers
+		}
+		base := strings.TrimSuffix(v, "+incompatible")
+		var errIncompatible error
+		if !module.MatchPathMajor(base, r.pathMajor) {
+			if canUseIncompatible() {
+				v = base + "+incompatible"
+			} else {
+				if r.pathMajor != "" {
+					errIncompatible = invalidf("module path includes a major version suffix, so major version must match")
+				} else {
+					errIncompatible = invalidf("module contains a go.mod file, so module path must match major version (%q)", path.Join(r.pathPrefix, semver.Major(v)))
+				}
+			}
+		} else if strings.HasSuffix(v, "+incompatible") {
+			errIncompatible = invalidf("+incompatible suffix not allowed: major version %s is compatible", semver.Major(v))
+		}
+
+		if statVers != "" && statVers == module.CanonicalVersion(statVers) {
+			// Since the caller-requested version is canonical, it would be very
+			// confusing to resolve it to anything but itself, possibly with a
+			// "+incompatible" suffix. Error out explicitly.
+			if statBase := strings.TrimSuffix(statVers, "+incompatible"); statBase != base {
+				return nil, &module.ModuleError{
+					Path: r.modPath,
+					Err: &module.InvalidVersionError{
+						Version: statVers,
+						Err:     fmt.Errorf("resolves to version %v (%s is not a tag)", v, statBase),
+					},
+				}
+			}
+		}
+
+		if errIncompatible != nil {
+			return nil, errIncompatible
+		}
+
+		return &RevInfo{
+			Name:    info.Name,
+			Short:   info.Short,
+			Time:    info.Time,
+			Version: v,
+		}, nil
 	}
 
 	// Determine version.
-	//
-	// If statVers is canonical, then the original call was repo.Stat(statVers).
-	// Since the version is canonical, we must not resolve it to anything but
-	// itself, possibly with a '+incompatible' annotation: we do not need to do
-	// the work required to look for an arbitrary pseudo-version.
-	if statVers != "" && statVers == module.CanonicalVersion(statVers) {
-		info2.Version = statVers
 
-		if module.IsPseudoVersion(info2.Version) {
-			if err := r.validatePseudoVersion(info, info2.Version); err != nil {
-				return nil, err
-			}
-			return checkGoMod()
+	if module.IsPseudoVersion(statVers) {
+		if err := r.validatePseudoVersion(info, statVers); err != nil {
+			return nil, err
 		}
-
-		if err := module.CheckPathMajor(info2.Version, r.pathMajor); err != nil {
-			if canUseIncompatible() {
-				info2.Version += "+incompatible"
-				return checkGoMod()
-			} else {
-				if vErr, ok := err.(*module.InvalidVersionError); ok {
-					// We're going to describe why the version is invalid in more detail,
-					// so strip out the existing “invalid version” wrapper.
-					err = vErr.Err
-				}
-				return nil, invalidf("module contains a go.mod file, so major version must be compatible: %v", err)
-			}
-		}
-
-		return checkGoMod()
+		return checkCanonical(statVers)
 	}
 
-	// statVers is empty or non-canonical, so we need to resolve it to a canonical
-	// version or pseudo-version.
+	// statVers is not a pseudo-version, so we need to either resolve it to a
+	// canonical version or verify that it is already a canonical tag
+	// (not a branch).
 
 	// Derive or verify a version from a code repo tag.
 	// Tag must have a prefix matching codeDir.
@@ -441,71 +449,62 @@
 		if v == "" || !strings.HasPrefix(trimmed, v) {
 			return "", false // Invalid or incomplete version (just vX or vX.Y).
 		}
-		if isRetracted(v) {
-			return "", false
-		}
 		if v == trimmed {
 			tagIsCanonical = true
 		}
-
-		if err := module.CheckPathMajor(v, r.pathMajor); err != nil {
-			if canUseIncompatible() {
-				return v + "+incompatible", tagIsCanonical
-			}
-			return "", false
-		}
-
 		return v, tagIsCanonical
 	}
 
 	// If the VCS gave us a valid version, use that.
 	if v, tagIsCanonical := tagToVersion(info.Version); tagIsCanonical {
-		info2.Version = v
-		return checkGoMod()
+		if info, err := checkCanonical(v); err == nil {
+			return info, err
+		}
 	}
 
 	// Look through the tags on the revision for either a usable canonical version
 	// or an appropriate base for a pseudo-version.
-	var pseudoBase string
+	var (
+		highestCanonical string
+		pseudoBase       string
+	)
 	for _, pathTag := range info.Tags {
 		v, tagIsCanonical := tagToVersion(pathTag)
-		if tagIsCanonical {
-			if statVers != "" && semver.Compare(v, statVers) == 0 {
-				// The user requested a non-canonical version, but the tag for the
-				// canonical equivalent refers to the same revision. Use it.
-				info2.Version = v
-				return checkGoMod()
+		if statVers != "" && semver.Compare(v, statVers) == 0 {
+			// The tag is equivalent to the version requested by the user.
+			if tagIsCanonical {
+				// This tag is the canonical form of the requested version,
+				// not some other form with extra build metadata.
+				// Use this tag so that the resolved version will match exactly.
+				// (If it isn't actually allowed, we'll error out in checkCanonical.)
+				return checkCanonical(v)
 			} else {
-				// Save the highest canonical tag for the revision. If we don't find a
-				// better match, we'll use it as the canonical version.
+				// The user explicitly requested something equivalent to this tag. We
+				// can't use the version from the tag directly: since the tag is not
+				// canonical, it could be ambiguous. For example, tags v0.0.1+a and
+				// v0.0.1+b might both exist and refer to different revisions.
 				//
-				// NOTE: Do not replace this with semver.Max. Despite the name,
-				// semver.Max *also* canonicalizes its arguments, which uses
-				// semver.Canonical instead of module.CanonicalVersion and thereby
-				// strips our "+incompatible" suffix.
-				if semver.Compare(info2.Version, v) < 0 {
-					info2.Version = v
-				}
+				// The tag is otherwise valid for the module, so we can at least use it as
+				// the base of an unambiguous pseudo-version.
+				//
+				// If multiple tags match, tagToVersion will canonicalize them to the same
+				// base version.
+				pseudoBase = v
 			}
-		} else if v != "" && semver.Compare(v, statVers) == 0 {
-			// The user explicitly requested something equivalent to this tag. We
-			// can't use the version from the tag directly: since the tag is not
-			// canonical, it could be ambiguous. For example, tags v0.0.1+a and
-			// v0.0.1+b might both exist and refer to different revisions.
-			//
-			// The tag is otherwise valid for the module, so we can at least use it as
-			// the base of an unambiguous pseudo-version.
-			//
-			// If multiple tags match, tagToVersion will canonicalize them to the same
-			// base version.
-			pseudoBase = v
+		}
+		// Save the highest non-retracted canonical tag for the revision.
+		// If we don't find a better match, we'll use it as the canonical version.
+		if tagIsCanonical && semver.Compare(highestCanonical, v) < 0 && !isRetracted(v) {
+			if module.MatchPathMajor(v, r.pathMajor) || canUseIncompatible() {
+				highestCanonical = v
+			}
 		}
 	}
 
-	// If we found any canonical tag for the revision, return it.
+	// If we found a valid canonical tag for the revision, return it.
 	// Even if we found a good pseudo-version base, a canonical version is better.
-	if info2.Version != "" {
-		return checkGoMod()
+	if highestCanonical != "" {
+		return checkCanonical(highestCanonical)
 	}
 
 	// Find the highest tagged version in the revision's history, subject to
@@ -528,11 +527,10 @@
 				tag, _ = r.code.RecentTag(info.Name, tagPrefix, allowedMajor("v0"))
 			}
 		}
-		pseudoBase, _ = tagToVersion(tag) // empty if the tag is invalid
+		pseudoBase, _ = tagToVersion(tag)
 	}
 
-	info2.Version = module.PseudoVersion(r.pseudoMajor, pseudoBase, info.Time, info.Short)
-	return checkGoMod()
+	return checkCanonical(module.PseudoVersion(r.pseudoMajor, pseudoBase, info.Time, info.Short))
 }
 
 // validatePseudoVersion checks that version has a major version compatible with
@@ -556,10 +554,6 @@
 		}
 	}()
 
-	if err := module.CheckPathMajor(version, r.pathMajor); err != nil {
-		return err
-	}
-
 	rev, err := module.PseudoVersionRev(version)
 	if err != nil {
 		return err
diff --git a/src/cmd/go/internal/modfetch/coderepo_test.go b/src/cmd/go/internal/modfetch/coderepo_test.go
index 02e399f..d98ea87 100644
--- a/src/cmd/go/internal/modfetch/coderepo_test.go
+++ b/src/cmd/go/internal/modfetch/coderepo_test.go
@@ -418,171 +418,204 @@
 		zipSum:      "h1:JItBZ+gwA5WvtZEGEbuDL4lUttGtLrs53lmdurq3bOg=",
 		zipFileHash: "9ea9ae1673cffcc44b7fdd3cc89953d68c102449b46c982dbf085e4f2e394da5",
 	},
+	{
+		// Git branch with a semver name, +incompatible version, and no go.mod file.
+		vcs:  "git",
+		path: "vcs-test.golang.org/go/mod/gitrepo1",
+		rev:  "v2.3.4+incompatible",
+		err:  `resolves to version v2.0.1+incompatible (v2.3.4 is not a tag)`,
+	},
+	{
+		// Git branch with a semver name, matching go.mod file, and compatible version.
+		vcs:  "git",
+		path: "vcs-test.golang.org/git/semver-branch.git",
+		rev:  "v1.0.0",
+		err:  `resolves to version v0.1.1-0.20220202191944-09c4d8f6938c (v1.0.0 is not a tag)`,
+	},
+	{
+		// Git branch with a semver name, matching go.mod file, and disallowed +incompatible version.
+		// The version/tag mismatch takes precedence over the +incompatible mismatched.
+		vcs:  "git",
+		path: "vcs-test.golang.org/git/semver-branch.git",
+		rev:  "v2.0.0+incompatible",
+		err:  `resolves to version v0.1.0 (v2.0.0 is not a tag)`,
+	},
+	{
+		// Git branch with a semver name, matching go.mod file, and mismatched version.
+		// The version/tag mismatch takes precedence over the +incompatible mismatched.
+		vcs:  "git",
+		path: "vcs-test.golang.org/git/semver-branch.git",
+		rev:  "v2.0.0",
+		err:  `resolves to version v0.1.0 (v2.0.0 is not a tag)`,
+	},
+	{
+		// v3.0.0-devel is the same as tag v4.0.0-beta.1, but v4.0.0-beta.1 would
+		// not be allowed because it is incompatible and a go.mod file exists.
+		// The error message should refer to a valid pseudo-version, not the
+		// unusable semver tag.
+		vcs:  "git",
+		path: "vcs-test.golang.org/git/semver-branch.git",
+		rev:  "v3.0.0-devel",
+		err:  `resolves to version v0.1.1-0.20220203155313-d59622f6e4d7 (v3.0.0-devel is not a tag)`,
+	},
 }
 
 func TestCodeRepo(t *testing.T) {
 	testenv.MustHaveExternalNetwork(t)
+	tmpdir := t.TempDir()
 
-	tmpdir, err := os.MkdirTemp("", "modfetch-test-")
-	if err != nil {
-		t.Fatal(err)
-	}
-	defer os.RemoveAll(tmpdir)
+	for _, tt := range codeRepoTests {
+		f := func(tt codeRepoTest) func(t *testing.T) {
+			return func(t *testing.T) {
+				t.Parallel()
+				if tt.vcs != "mod" {
+					testenv.MustHaveExecPath(t, tt.vcs)
+				}
 
-	t.Run("parallel", func(t *testing.T) {
-		for _, tt := range codeRepoTests {
-			f := func(tt codeRepoTest) func(t *testing.T) {
-				return func(t *testing.T) {
-					t.Parallel()
-					if tt.vcs != "mod" {
-						testenv.MustHaveExecPath(t, tt.vcs)
-					}
+				repo := Lookup("direct", tt.path)
 
-					repo := Lookup("direct", tt.path)
+				if tt.mpath == "" {
+					tt.mpath = tt.path
+				}
+				if mpath := repo.ModulePath(); mpath != tt.mpath {
+					t.Errorf("repo.ModulePath() = %q, want %q", mpath, tt.mpath)
+				}
 
-					if tt.mpath == "" {
-						tt.mpath = tt.path
-					}
-					if mpath := repo.ModulePath(); mpath != tt.mpath {
-						t.Errorf("repo.ModulePath() = %q, want %q", mpath, tt.mpath)
-					}
-
-					info, err := repo.Stat(tt.rev)
-					if err != nil {
-						if tt.err != "" {
-							if !strings.Contains(err.Error(), tt.err) {
-								t.Fatalf("repoStat(%q): %v, wanted %q", tt.rev, err, tt.err)
-							}
-							return
-						}
-						t.Fatalf("repo.Stat(%q): %v", tt.rev, err)
-					}
+				info, err := repo.Stat(tt.rev)
+				if err != nil {
 					if tt.err != "" {
-						t.Errorf("repo.Stat(%q): success, wanted error", tt.rev)
+						if !strings.Contains(err.Error(), tt.err) {
+							t.Fatalf("repoStat(%q): %v, wanted %q", tt.rev, err, tt.err)
+						}
+						return
 					}
-					if info.Version != tt.version {
-						t.Errorf("info.Version = %q, want %q", info.Version, tt.version)
-					}
-					if info.Name != tt.name {
-						t.Errorf("info.Name = %q, want %q", info.Name, tt.name)
-					}
-					if info.Short != tt.short {
-						t.Errorf("info.Short = %q, want %q", info.Short, tt.short)
-					}
-					if !info.Time.Equal(tt.time) {
-						t.Errorf("info.Time = %v, want %v", info.Time, tt.time)
-					}
+					t.Fatalf("repo.Stat(%q): %v", tt.rev, err)
+				}
+				if tt.err != "" {
+					t.Errorf("repo.Stat(%q): success, wanted error", tt.rev)
+				}
+				if info.Version != tt.version {
+					t.Errorf("info.Version = %q, want %q", info.Version, tt.version)
+				}
+				if info.Name != tt.name {
+					t.Errorf("info.Name = %q, want %q", info.Name, tt.name)
+				}
+				if info.Short != tt.short {
+					t.Errorf("info.Short = %q, want %q", info.Short, tt.short)
+				}
+				if !info.Time.Equal(tt.time) {
+					t.Errorf("info.Time = %v, want %v", info.Time, tt.time)
+				}
 
-					if tt.gomod != "" || tt.gomodErr != "" {
-						data, err := repo.GoMod(tt.version)
-						if err != nil && tt.gomodErr == "" {
-							t.Errorf("repo.GoMod(%q): %v", tt.version, err)
-						} else if err != nil && tt.gomodErr != "" {
-							if err.Error() != tt.gomodErr {
-								t.Errorf("repo.GoMod(%q): %v, want %q", tt.version, err, tt.gomodErr)
-							}
-						} else if tt.gomodErr != "" {
-							t.Errorf("repo.GoMod(%q) = %q, want error %q", tt.version, data, tt.gomodErr)
-						} else if string(data) != tt.gomod {
-							t.Errorf("repo.GoMod(%q) = %q, want %q", tt.version, data, tt.gomod)
+				if tt.gomod != "" || tt.gomodErr != "" {
+					data, err := repo.GoMod(tt.version)
+					if err != nil && tt.gomodErr == "" {
+						t.Errorf("repo.GoMod(%q): %v", tt.version, err)
+					} else if err != nil && tt.gomodErr != "" {
+						if err.Error() != tt.gomodErr {
+							t.Errorf("repo.GoMod(%q): %v, want %q", tt.version, err, tt.gomodErr)
 						}
-					}
-
-					needHash := !testing.Short() && (tt.zipFileHash != "" || tt.zipSum != "")
-					if tt.zip != nil || tt.zipErr != "" || needHash {
-						f, err := os.CreateTemp(tmpdir, tt.version+".zip.")
-						if err != nil {
-							t.Fatalf("os.CreateTemp: %v", err)
-						}
-						zipfile := f.Name()
-						defer func() {
-							f.Close()
-							os.Remove(zipfile)
-						}()
-
-						var w io.Writer
-						var h hash.Hash
-						if needHash {
-							h = sha256.New()
-							w = io.MultiWriter(f, h)
-						} else {
-							w = f
-						}
-						err = repo.Zip(w, tt.version)
-						f.Close()
-						if err != nil {
-							if tt.zipErr != "" {
-								if err.Error() == tt.zipErr {
-									return
-								}
-								t.Fatalf("repo.Zip(%q): %v, want error %q", tt.version, err, tt.zipErr)
-							}
-							t.Fatalf("repo.Zip(%q): %v", tt.version, err)
-						}
-						if tt.zipErr != "" {
-							t.Errorf("repo.Zip(%q): success, want error %q", tt.version, tt.zipErr)
-						}
-
-						if tt.zip != nil {
-							prefix := tt.path + "@" + tt.version + "/"
-							z, err := zip.OpenReader(zipfile)
-							if err != nil {
-								t.Fatalf("open zip %s: %v", zipfile, err)
-							}
-							var names []string
-							for _, file := range z.File {
-								if !strings.HasPrefix(file.Name, prefix) {
-									t.Errorf("zip entry %v does not start with prefix %v", file.Name, prefix)
-									continue
-								}
-								names = append(names, file.Name[len(prefix):])
-							}
-							z.Close()
-							if !reflect.DeepEqual(names, tt.zip) {
-								t.Fatalf("zip = %v\nwant %v\n", names, tt.zip)
-							}
-						}
-
-						if needHash {
-							sum, err := dirhash.HashZip(zipfile, dirhash.Hash1)
-							if err != nil {
-								t.Errorf("repo.Zip(%q): %v", tt.version, err)
-							} else if sum != tt.zipSum {
-								t.Errorf("repo.Zip(%q): got file with sum %q, want %q", tt.version, sum, tt.zipSum)
-							} else if zipFileHash := hex.EncodeToString(h.Sum(nil)); zipFileHash != tt.zipFileHash {
-								t.Errorf("repo.Zip(%q): got file with hash %q, want %q (but content has correct sum)", tt.version, zipFileHash, tt.zipFileHash)
-							}
-						}
+					} else if tt.gomodErr != "" {
+						t.Errorf("repo.GoMod(%q) = %q, want error %q", tt.version, data, tt.gomodErr)
+					} else if string(data) != tt.gomod {
+						t.Errorf("repo.GoMod(%q) = %q, want %q", tt.version, data, tt.gomod)
 					}
 				}
-			}
-			t.Run(strings.ReplaceAll(tt.path, "/", "_")+"/"+tt.rev, f(tt))
-			if strings.HasPrefix(tt.path, vgotest1git) {
-				for vcs, alt := range altVgotests {
-					altTest := tt
-					altTest.vcs = vcs
-					altTest.path = alt + strings.TrimPrefix(altTest.path, vgotest1git)
-					if strings.HasPrefix(altTest.mpath, vgotest1git) {
-						altTest.mpath = alt + strings.TrimPrefix(altTest.mpath, vgotest1git)
+
+				needHash := !testing.Short() && (tt.zipFileHash != "" || tt.zipSum != "")
+				if tt.zip != nil || tt.zipErr != "" || needHash {
+					f, err := os.CreateTemp(tmpdir, tt.version+".zip.")
+					if err != nil {
+						t.Fatalf("os.CreateTemp: %v", err)
 					}
-					var m map[string]string
-					if alt == vgotest1hg {
-						m = hgmap
+					zipfile := f.Name()
+					defer func() {
+						f.Close()
+						os.Remove(zipfile)
+					}()
+
+					var w io.Writer
+					var h hash.Hash
+					if needHash {
+						h = sha256.New()
+						w = io.MultiWriter(f, h)
+					} else {
+						w = f
 					}
-					altTest.version = remap(altTest.version, m)
-					altTest.name = remap(altTest.name, m)
-					altTest.short = remap(altTest.short, m)
-					altTest.rev = remap(altTest.rev, m)
-					altTest.err = remap(altTest.err, m)
-					altTest.gomodErr = remap(altTest.gomodErr, m)
-					altTest.zipErr = remap(altTest.zipErr, m)
-					altTest.zipSum = ""
-					altTest.zipFileHash = ""
-					t.Run(strings.ReplaceAll(altTest.path, "/", "_")+"/"+altTest.rev, f(altTest))
+					err = repo.Zip(w, tt.version)
+					f.Close()
+					if err != nil {
+						if tt.zipErr != "" {
+							if err.Error() == tt.zipErr {
+								return
+							}
+							t.Fatalf("repo.Zip(%q): %v, want error %q", tt.version, err, tt.zipErr)
+						}
+						t.Fatalf("repo.Zip(%q): %v", tt.version, err)
+					}
+					if tt.zipErr != "" {
+						t.Errorf("repo.Zip(%q): success, want error %q", tt.version, tt.zipErr)
+					}
+
+					if tt.zip != nil {
+						prefix := tt.path + "@" + tt.version + "/"
+						z, err := zip.OpenReader(zipfile)
+						if err != nil {
+							t.Fatalf("open zip %s: %v", zipfile, err)
+						}
+						var names []string
+						for _, file := range z.File {
+							if !strings.HasPrefix(file.Name, prefix) {
+								t.Errorf("zip entry %v does not start with prefix %v", file.Name, prefix)
+								continue
+							}
+							names = append(names, file.Name[len(prefix):])
+						}
+						z.Close()
+						if !reflect.DeepEqual(names, tt.zip) {
+							t.Fatalf("zip = %v\nwant %v\n", names, tt.zip)
+						}
+					}
+
+					if needHash {
+						sum, err := dirhash.HashZip(zipfile, dirhash.Hash1)
+						if err != nil {
+							t.Errorf("repo.Zip(%q): %v", tt.version, err)
+						} else if sum != tt.zipSum {
+							t.Errorf("repo.Zip(%q): got file with sum %q, want %q", tt.version, sum, tt.zipSum)
+						} else if zipFileHash := hex.EncodeToString(h.Sum(nil)); zipFileHash != tt.zipFileHash {
+							t.Errorf("repo.Zip(%q): got file with hash %q, want %q (but content has correct sum)", tt.version, zipFileHash, tt.zipFileHash)
+						}
+					}
 				}
 			}
 		}
-	})
+		t.Run(strings.ReplaceAll(tt.path, "/", "_")+"/"+tt.rev, f(tt))
+		if strings.HasPrefix(tt.path, vgotest1git) {
+			for vcs, alt := range altVgotests {
+				altTest := tt
+				altTest.vcs = vcs
+				altTest.path = alt + strings.TrimPrefix(altTest.path, vgotest1git)
+				if strings.HasPrefix(altTest.mpath, vgotest1git) {
+					altTest.mpath = alt + strings.TrimPrefix(altTest.mpath, vgotest1git)
+				}
+				var m map[string]string
+				if alt == vgotest1hg {
+					m = hgmap
+				}
+				altTest.version = remap(altTest.version, m)
+				altTest.name = remap(altTest.name, m)
+				altTest.short = remap(altTest.short, m)
+				altTest.rev = remap(altTest.rev, m)
+				altTest.err = remap(altTest.err, m)
+				altTest.gomodErr = remap(altTest.gomodErr, m)
+				altTest.zipErr = remap(altTest.zipErr, m)
+				altTest.zipSum = ""
+				altTest.zipFileHash = ""
+				t.Run(strings.ReplaceAll(altTest.path, "/", "_")+"/"+altTest.rev, f(altTest))
+			}
+		}
+	}
 }
 
 var hgmap = map[string]string{
diff --git a/src/cmd/go/testdata/script/mod_invalid_version.txt b/src/cmd/go/testdata/script/mod_invalid_version.txt
index 428b8aa..8385b08d 100644
--- a/src/cmd/go/testdata/script/mod_invalid_version.txt
+++ b/src/cmd/go/testdata/script/mod_invalid_version.txt
@@ -194,10 +194,10 @@
 go mod edit -require github.com/pierrec/lz4@v2.0.9-0.20190209155647-9a39efadad3d+incompatible
 cd outside
 ! go list -m github.com/pierrec/lz4
-stderr 'go: example.com@v0.0.0 requires\n\tgithub.com/pierrec/lz4@v2.0.9-0.20190209155647-9a39efadad3d\+incompatible: invalid version: \+incompatible suffix not allowed: module contains a go.mod file, so semantic import versioning is required'
+stderr '^go: example.com@v0.0.0 requires\n\tgithub.com/pierrec/lz4@v2.0.9-0.20190209155647-9a39efadad3d\+incompatible: invalid version: module contains a go.mod file, so module path must match major version \("github.com/pierrec/lz4/v2"\)$'
 cd ..
 ! go list -m github.com/pierrec/lz4
-stderr 'github.com/pierrec/lz4@v2.0.9-0.20190209155647-9a39efadad3d\+incompatible: invalid version: \+incompatible suffix not allowed: module contains a go.mod file, so semantic import versioning is required'
+stderr '^go: github.com/pierrec/lz4@v2.0.9-0.20190209155647-9a39efadad3d\+incompatible: invalid version: module contains a go.mod file, so module path must match major version \("github.com/pierrec/lz4/v2"\)$'
 
 # A +incompatible pseudo-version is valid for a revision of the module
 # that lacks a go.mod file.
@@ -222,7 +222,7 @@
 # not resolve to a pseudo-version with a different major version.
 cp go.mod.orig go.mod
 ! go get github.com/pierrec/lz4@v2.0.8
-stderr 'go: github.com/pierrec/lz4@v2.0.8: invalid version: module contains a go.mod file, so major version must be compatible: should be v0 or v1, not v2'
+stderr 'go: github.com/pierrec/lz4@v2.0.8: invalid version: module contains a go.mod file, so module path must match major version \("github.com/pierrec/lz4/v2"\)$'
 
 # An invalid +incompatible suffix for a canonical version should error out,
 # not resolve to a pseudo-version.
@@ -233,10 +233,10 @@
 go mod edit -require github.com/pierrec/lz4@v2.0.8+incompatible
 cd outside
 ! go list -m github.com/pierrec/lz4
-stderr 'github.com/pierrec/lz4@v2.0.8\+incompatible: invalid version: \+incompatible suffix not allowed: module contains a go.mod file, so semantic import versioning is required'
+stderr '^go: github.com/pierrec/lz4@v2.0.8\+incompatible: invalid version: module contains a go.mod file, so module path must match major version \("github.com/pierrec/lz4/v2"\)$'
 cd ..
 ! go list -m github.com/pierrec/lz4
-stderr 'github.com/pierrec/lz4@v2.0.8\+incompatible: invalid version: \+incompatible suffix not allowed: module contains a go.mod file, so semantic import versioning is required'
+stderr '^go: github.com/pierrec/lz4@v2.0.8\+incompatible: invalid version: module contains a go.mod file, so module path must match major version \("github.com/pierrec/lz4/v2"\)$'
 
 -- go.mod.orig --
 module example.com