cmd/gorelease: support comparison of modules with different paths

For golang/go#39666

Change-Id: I7e69ef14a3f6b3996ceea2a70ec4ce1e33f912c6
Reviewed-on: https://go-review.googlesource.com/c/exp/+/238839
Trust: Jay Conrod <jayconrod@google.com>
Run-TryBot: Jay Conrod <jayconrod@google.com>
TryBot-Result: Go Bot <gobot@golang.org>
Reviewed-by: Michael Matloob <matloob@golang.org>
diff --git a/cmd/gorelease/gorelease.go b/cmd/gorelease/gorelease.go
index e024710..83ed99c 100644
--- a/cmd/gorelease/gorelease.go
+++ b/cmd/gorelease/gorelease.go
@@ -64,8 +64,11 @@
 // "latest", or "none". If the version is "none", gorelease will not compare the
 // current version against any previous version; it will only validate the
 // current version. This is useful for checking the first release of a new major
-// version. If -base is not specified, gorelease will attempt to infer a base
-// version from the -version flag and available released versions.
+// version. The version may be preceded by a different module path and an '@',
+// like -base=example.com/mod/v2@v2.5.2. This is useful to compare against
+// an earlier major version or a fork. If -base is not specified, gorelease will
+// attempt to infer a base version from the -version flag and available released
+// versions.
 //
 // -version=version: The proposed version to be released. If specified,
 // gorelease will confirm whether this version is consistent with changes made
@@ -160,8 +163,8 @@
 	fs := flag.NewFlagSet("gorelease", flag.ContinueOnError)
 	fs.Usage = func() {}
 	fs.SetOutput(ioutil.Discard)
-	var baseVersion, releaseVersion string
-	fs.StringVar(&baseVersion, "base", "", "previous version to compare against")
+	var baseOpt, releaseVersion string
+	fs.StringVar(&baseOpt, "base", "", "previous version to compare against")
 	fs.StringVar(&releaseVersion, "version", "", "proposed version to be released")
 	if err := fs.Parse(args); err != nil {
 		return false, &usageError{err: err}
@@ -170,6 +173,7 @@
 	if len(fs.Args()) > 0 {
 		return false, usageErrorf("no arguments allowed")
 	}
+
 	if releaseVersion != "" {
 		if semver.Build(releaseVersion) != "" {
 			return false, usageErrorf("release version %q is not a canonical semantic version: build metadata is not supported", releaseVersion)
@@ -178,12 +182,26 @@
 			return false, usageErrorf("release version %q is not a canonical semantic version", releaseVersion)
 		}
 	}
-	if baseVersion != "" && semver.Canonical(baseVersion) == baseVersion && releaseVersion != "" {
-		if cmp := semver.Compare(baseVersion, releaseVersion); cmp == 0 {
-			return false, usageErrorf("-base and -version must be different")
-		} else if cmp > 0 {
-			return false, usageErrorf("base version (%q) must be lower than release version (%q)", baseVersion, releaseVersion)
+
+	var baseModPath, baseVersion string
+	if at := strings.Index(baseOpt, "@"); at >= 0 {
+		baseModPath = baseOpt[:at]
+		baseVersion = baseOpt[at+1:]
+	} else if dot, slash := strings.Index(baseOpt, "."), strings.Index(baseOpt, "/"); dot >= 0 && slash >= 0 && dot < slash {
+		baseModPath = baseOpt
+	} else {
+		baseVersion = baseOpt
+	}
+	if baseModPath == "" {
+		if baseVersion != "" && semver.Canonical(baseVersion) == baseVersion && releaseVersion != "" {
+			if cmp := semver.Compare(baseOpt, releaseVersion); cmp == 0 {
+				return false, usageErrorf("-base and -version must be different")
+			} else if cmp > 0 {
+				return false, usageErrorf("base version (%q) must be lower than release version (%q)", baseVersion, releaseVersion)
+			}
 		}
+	} else if baseModPath != "" && baseVersion == "none" {
+		return false, usageErrorf(`base version (%q) cannot have version "none" with explicit module path`, baseOpt)
 	}
 
 	// Find the local module and repository root directories.
@@ -201,8 +219,12 @@
 
 	// Find the base version if there is one, download it, and load packages from
 	// the module cache.
-	baseModPath := release.modPath // TODO(golang.org/issue/39666): allow different module path
-	base, err := loadDownloadedModule(baseModPath, baseVersion, releaseVersion)
+	var max string
+	if baseModPath == "" {
+		baseModPath = release.modPath
+		max = releaseVersion
+	}
+	base, err := loadDownloadedModule(baseModPath, baseVersion, max)
 	if err != nil {
 		return false, err
 	}
@@ -381,6 +403,9 @@
 // to max will not be considered. Typically, loadDownloadedModule is used to
 // load the base version, and max is the release version.
 func loadDownloadedModule(modPath, version, max string) (m moduleInfo, err error) {
+	// TODO(#39666): support downloaded modules that are "soft forks", where the
+	// module path in go.mod is different from modPath.
+
 	// Check the module path and version.
 	// If the version is a query, resolve it to a canonical version.
 	m = moduleInfo{modPath: modPath}
@@ -474,18 +499,12 @@
 // version control tag to use (with an appropriate prefix, for modules not
 // in the repository root directory).
 func makeReleaseReport(base, release moduleInfo) (report, error) {
-	if base.modPath != release.modPath {
-		// TODO(golang.org/issue/39666): allow base and release path to be different.
-		panic(fmt.Sprintf("base module path %q is different than release module path %q", base.modPath, release.modPath))
-	}
-	modPath := release.modPath
-
 	// Compare each pair of packages.
 	// Ignore internal packages.
 	// If we don't have a base version to compare against,
 	// just check the new packages for errors.
 	shouldCompare := base.version != "none"
-	isInternal := func(pkgPath string) bool {
+	isInternal := func(modPath, pkgPath string) bool {
 		if !hasPathPrefix(pkgPath, modPath) {
 			panic(fmt.Sprintf("package %s not in module %s", pkgPath, modPath))
 		}
@@ -501,17 +520,17 @@
 		base:    base,
 		release: release,
 	}
-	for _, pair := range zipPackages(base.pkgs, release.pkgs) {
+	for _, pair := range zipPackages(base.modPath, base.pkgs, release.modPath, release.pkgs) {
 		basePkg, releasePkg := pair.base, pair.release
 		switch {
 		case releasePkg == nil:
 			// Package removed
-			if !isInternal(basePkg.PkgPath) || len(basePkg.Errors) > 0 {
+			if internal := isInternal(base.modPath, basePkg.PkgPath); !internal || len(basePkg.Errors) > 0 {
 				pr := packageReport{
 					path:       basePkg.PkgPath,
 					baseErrors: basePkg.Errors,
 				}
-				if !isInternal(basePkg.PkgPath) {
+				if !internal {
 					pr.Report = apidiff.Report{
 						Changes: []apidiff.Change{{
 							Message:    "package removed",
@@ -524,12 +543,12 @@
 
 		case basePkg == nil:
 			// Package added
-			if !isInternal(releasePkg.PkgPath) && shouldCompare || len(releasePkg.Errors) > 0 {
+			if internal := isInternal(release.modPath, releasePkg.PkgPath); !internal && shouldCompare || len(releasePkg.Errors) > 0 {
 				pr := packageReport{
 					path:          releasePkg.PkgPath,
 					releaseErrors: releasePkg.Errors,
 				}
-				if !isInternal(releasePkg.PkgPath) && shouldCompare {
+				if !internal && shouldCompare {
 					// If we aren't comparing against a base version, don't say
 					// "package added". Only report packages with errors.
 					pr.Report = apidiff.Report{
@@ -544,7 +563,10 @@
 
 		default:
 			// Matched packages
-			if !isInternal(basePkg.PkgPath) && basePkg.Name != "main" && releasePkg.Name != "main" {
+			// Both packages are internal or neither; we only consider path components
+			// after the module path.
+			internal := isInternal(release.modPath, releasePkg.PkgPath)
+			if !internal && basePkg.Name != "main" && releasePkg.Name != "main" {
 				pr := packageReport{
 					path:          basePkg.PkgPath,
 					baseErrors:    basePkg.Errors,
@@ -558,7 +580,7 @@
 
 	if release.version != "" {
 		r.validateVersion()
-	} else {
+	} else if r.similarModPaths() {
 		r.suggestVersion()
 	}
 
@@ -1056,24 +1078,27 @@
 // If a package is in one list but not the other (because it was added or
 // removed between releases), a pair will be returned with a nil
 // base or release field.
-func zipPackages(basePkgs, releasePkgs []*packages.Package) []packagePair {
+func zipPackages(baseModPath string, basePkgs []*packages.Package, releaseModPath string, releasePkgs []*packages.Package) []packagePair {
 	baseIndex, releaseIndex := 0, 0
 	var pairs []packagePair
 	for baseIndex < len(basePkgs) || releaseIndex < len(releasePkgs) {
 		var basePkg, releasePkg *packages.Package
+		var baseSuffix, releaseSuffix string
 		if baseIndex < len(basePkgs) {
 			basePkg = basePkgs[baseIndex]
+			baseSuffix = trimPathPrefix(basePkg.PkgPath, baseModPath)
 		}
 		if releaseIndex < len(releasePkgs) {
 			releasePkg = releasePkgs[releaseIndex]
+			releaseSuffix = trimPathPrefix(releasePkg.PkgPath, releaseModPath)
 		}
 
 		var pair packagePair
-		if basePkg != nil && (releasePkg == nil || basePkg.PkgPath < releasePkg.PkgPath) {
+		if basePkg != nil && (releasePkg == nil || baseSuffix < releaseSuffix) {
 			// Package removed
 			pair = packagePair{basePkg, nil}
 			baseIndex++
-		} else if releasePkg != nil && (basePkg == nil || releasePkg.PkgPath < basePkg.PkgPath) {
+		} else if releasePkg != nil && (basePkg == nil || releaseSuffix < baseSuffix) {
 			// Package added
 			pair = packagePair{nil, releasePkg}
 			releaseIndex++
diff --git a/cmd/gorelease/report.go b/cmd/gorelease/report.go
index cea58a4..fdf6d3e 100644
--- a/cmd/gorelease/report.go
+++ b/cmd/gorelease/report.go
@@ -65,10 +65,14 @@
 		}
 	}
 
+	baseVersion := r.base.version
+	if r.base.modPath != r.release.modPath {
+		baseVersion = r.base.modPath + "@" + baseVersion
+	}
 	if r.base.versionInferred {
-		fmt.Fprintf(buf, "Inferred base version: %s\n", r.base.version)
+		fmt.Fprintf(buf, "Inferred base version: %s\n", baseVersion)
 	} else if r.base.versionQuery != "" {
-		fmt.Fprintf(buf, "Base version: %s (%s)\n", r.base.version, r.base.versionQuery)
+		fmt.Fprintf(buf, "Base version: %s (%s)\n", baseVersion, r.base.versionQuery)
 	}
 
 	if len(r.release.diagnostics) > 0 {
@@ -83,7 +87,7 @@
 		} else {
 			fmt.Fprintf(buf, "Suggested version: %[1]s (with tag %[2]s%[1]s)\n", r.release.version, r.release.tagPrefix)
 		}
-	} else {
+	} else if r.release.version != "" && r.similarModPaths() {
 		if r.release.tagPrefix == "" {
 			fmt.Fprintf(buf, "%s is a valid semantic version for this release.\n", r.release.version)
 
@@ -175,7 +179,7 @@
 	}
 
 	// Check that compatible / incompatible changes are consistent.
-	if semver.Major(r.base.version) == "v0" {
+	if semver.Major(r.base.version) == "v0" || r.base.modPath != r.release.modPath {
 		return
 	}
 	if r.haveIncompatibleChanges {
@@ -202,6 +206,11 @@
 		r.release.versionInferred = true
 	}
 
+	if r.base.modPath != r.release.modPath {
+		setNotValid("Base module path is different from release.")
+		return
+	}
+
 	if r.haveReleaseErrors || r.haveBaseErrors {
 		setNotValid("Errors were found.")
 		return
@@ -245,6 +254,14 @@
 	setVersion(fmt.Sprintf("v%s.%s.%s", major, minor, patch))
 }
 
+// similarModPaths returns true if r.base and r.release are versions of the same
+// module or different major versions of the same module.
+func (r *report) similarModPaths() bool {
+	basePath := strings.TrimSuffix(r.base.modPath, r.base.modPathMajor)
+	releasePath := strings.TrimSuffix(r.release.modPath, r.release.modPathMajor)
+	return basePath == releasePath
+}
+
 // requirementsChanged reports whether requirements have changed from base to
 // version.
 //
diff --git a/cmd/gorelease/testdata/basic/v1_fork_base_modpath_version_verify.test b/cmd/gorelease/testdata/basic/v1_fork_base_modpath_version_verify.test
new file mode 100644
index 0000000..eaa57be
--- /dev/null
+++ b/cmd/gorelease/testdata/basic/v1_fork_base_modpath_version_verify.test
@@ -0,0 +1,11 @@
+mod=example.com/basicfork
+base=example.com/basic@v1.1.1
+version=v1.1.2
+release=v1.1.2
+-- want --
+example.com/basic/a
+-------------------
+Incompatible changes:
+- A2: removed
+Compatible changes:
+- A3: added
diff --git a/cmd/gorelease/testdata/basic/v1_v2_base_modpath_query_verify.test b/cmd/gorelease/testdata/basic/v1_v2_base_modpath_query_verify.test
new file mode 100644
index 0000000..1d56841
--- /dev/null
+++ b/cmd/gorelease/testdata/basic/v1_v2_base_modpath_query_verify.test
@@ -0,0 +1,17 @@
+mod=example.com/basic/v2
+base=example.com/basic@>=v1.1.0
+version=v2.0.1
+release=v2.0.1
+-- want --
+example.com/basic/a
+-------------------
+Incompatible changes:
+- A2: removed
+
+example.com/basic/b
+-------------------
+Incompatible changes:
+- package removed
+
+Base version: example.com/basic@v1.1.0 (>=v1.1.0)
+v2.0.1 is a valid semantic version for this release.
diff --git a/cmd/gorelease/testdata/basic/v1_v2_base_modpath_suggest.test b/cmd/gorelease/testdata/basic/v1_v2_base_modpath_suggest.test
new file mode 100644
index 0000000..5f916a9
--- /dev/null
+++ b/cmd/gorelease/testdata/basic/v1_v2_base_modpath_suggest.test
@@ -0,0 +1,18 @@
+mod=example.com/basic/v2
+base=example.com/basic
+version=v2.1.0
+success=false
+-- want --
+example.com/basic/a
+-------------------
+Compatible changes:
+- A2: added
+
+example.com/basic/v2/b
+----------------------
+Compatible changes:
+- package added
+
+Inferred base version: example.com/basic@v1.1.2
+Cannot suggest a release version.
+Base module path is different from release.
diff --git a/cmd/gorelease/testdata/basic/v1_v2_base_modpath_verify.test b/cmd/gorelease/testdata/basic/v1_v2_base_modpath_verify.test
new file mode 100644
index 0000000..6533f3a
--- /dev/null
+++ b/cmd/gorelease/testdata/basic/v1_v2_base_modpath_verify.test
@@ -0,0 +1,17 @@
+mod=example.com/basic/v2
+base=example.com/basic
+version=v2.1.0
+release=v2.1.0
+-- want --
+example.com/basic/a
+-------------------
+Compatible changes:
+- A2: added
+
+example.com/basic/v2/b
+----------------------
+Compatible changes:
+- package added
+
+Inferred base version: example.com/basic@v1.1.2
+v2.1.0 is a valid semantic version for this release.
diff --git a/cmd/gorelease/testdata/basic/v1_v2_base_modpath_version_verify.test b/cmd/gorelease/testdata/basic/v1_v2_base_modpath_version_verify.test
new file mode 100644
index 0000000..22ed2f4
--- /dev/null
+++ b/cmd/gorelease/testdata/basic/v1_v2_base_modpath_version_verify.test
@@ -0,0 +1,16 @@
+mod=example.com/basic/v2
+base=example.com/basic@v1.1.0
+version=v2.0.1
+release=v2.0.1
+-- want --
+example.com/basic/a
+-------------------
+Incompatible changes:
+- A2: removed
+
+example.com/basic/b
+-------------------
+Incompatible changes:
+- package removed
+
+v2.0.1 is a valid semantic version for this release.
diff --git a/cmd/gorelease/testdata/errors/base_modpath_none.test b/cmd/gorelease/testdata/errors/base_modpath_none.test
new file mode 100644
index 0000000..bb438d9
--- /dev/null
+++ b/cmd/gorelease/testdata/errors/base_modpath_none.test
@@ -0,0 +1,8 @@
+mod=example.com/basic/v2
+base=example.com/basic@none
+error=true
+
+-- want --
+usage: gorelease [-base=version] [-version=version]
+base version ("example.com/basic@none") cannot have version "none" with explicit module path
+For more information, run go doc golang.org/x/exp/cmd/gorelease
diff --git a/cmd/gorelease/testdata/internalcompat/README.txt b/cmd/gorelease/testdata/internalcompat/README.txt
new file mode 100644
index 0000000..e3de7e5
--- /dev/null
+++ b/cmd/gorelease/testdata/internalcompat/README.txt
@@ -0,0 +1,20 @@
+Modules example.com/internalcompat/{a,b} are copies. One could be a fork
+of the other. An external package p exposes a type from a package q
+within the same module.
+
+gorelease should not report differences between these packages. The types
+are distinct, but they correspond (in apidiff terminology), which is the
+important property when considering differences between modules.
+
+There are three use cases to consider:
+
+1. One module substitutes for the other via a `replace` directive.
+   Only the replacement module is used, and the package paths are effectively
+   identical, so the types are not distinct.
+2. One module subsititutes for the other by rewriting `import` statements
+   globally. All references to the original type become references to the
+   new type, so there is no conflict.
+3. One module substitutes for the other by rewriting some `import` statements
+   but not others (for example, those within a specific consumer package).
+   In this case, the types are distinct, and even if there are no changes,
+   the types are not compatible.
diff --git a/cmd/gorelease/testdata/internalcompat/internalcompat.test b/cmd/gorelease/testdata/internalcompat/internalcompat.test
new file mode 100644
index 0000000..8bc32fa
--- /dev/null
+++ b/cmd/gorelease/testdata/internalcompat/internalcompat.test
@@ -0,0 +1,6 @@
+mod=example.com/internalcompat/b
+version=v1.0.0
+release=v1.0.0
+base=example.com/internalcompat/a@v1.0.0
+
+-- want --
diff --git a/cmd/gorelease/testdata/mod/example.com_basicfork_v1.1.2.txt b/cmd/gorelease/testdata/mod/example.com_basicfork_v1.1.2.txt
new file mode 100644
index 0000000..63cb887
--- /dev/null
+++ b/cmd/gorelease/testdata/mod/example.com_basicfork_v1.1.2.txt
@@ -0,0 +1,13 @@
+-- go.mod --
+module example.com/basicfork
+
+go 1.12
+-- a/a.go --
+package a
+
+func A() int { return 1 }
+func A3() int { return 4 }
+-- b/b.go --
+package b
+
+func B() int { return 3 }
diff --git a/cmd/gorelease/testdata/mod/example.com_internalcompat_a_v1.0.0.txt b/cmd/gorelease/testdata/mod/example.com_internalcompat_a_v1.0.0.txt
new file mode 100644
index 0000000..5f15418
--- /dev/null
+++ b/cmd/gorelease/testdata/mod/example.com_internalcompat_a_v1.0.0.txt
@@ -0,0 +1,16 @@
+-- go.mod --
+module example.com/internalcompat/a
+
+go 1.15
+
+-- p/p.go --
+package p
+
+import "example.com/internalcompat/a/q"
+
+var V q.Q
+
+-- q/q.go --
+package q
+
+type Q struct{}
diff --git a/cmd/gorelease/testdata/mod/example.com_internalcompat_b_v1.0.0.txt b/cmd/gorelease/testdata/mod/example.com_internalcompat_b_v1.0.0.txt
new file mode 100644
index 0000000..318db36
--- /dev/null
+++ b/cmd/gorelease/testdata/mod/example.com_internalcompat_b_v1.0.0.txt
@@ -0,0 +1,16 @@
+-- go.mod --
+module example.com/internalcompat/b
+
+go 1.15
+
+-- p/p.go --
+package p
+
+import "example.com/internalcompat/b/q"
+
+var V q.Q
+
+-- q/q.go --
+package q
+
+type Q struct{}