x/exp/cmd/gorelease: avoid using -mod=mod and support lazy module loading

Details:

- Replaces `go list -mod=mod` with `go list -mod=readonly`.
- Switches to `go get -d` to download dependencies rather than relying on `go
  list` doing so.
- Missing go.sum/go.mod data is now an error.

This CL will be followed by a CL that ensures the filepath.Walk stops at
submodules, and a test asserting that. I figured it'd be good to do separately
since this CL is getting to be hefty. Give a shout if you prefer otherwise!

Updates golang/go#41456

Change-Id: I66f12fe0325e4fed432d8b10c1fe977068e4a030
Reviewed-on: https://go-review.googlesource.com/c/exp/+/263178
Run-TryBot: Jean de Klerk <deklerk@google.com>
TryBot-Result: Go Bot <gobot@golang.org>
Reviewed-by: Jay Conrod <jayconrod@google.com>
Trust: Jean de Klerk <deklerk@google.com>
diff --git a/cmd/gorelease/gorelease.go b/cmd/gorelease/gorelease.go
index 005984c..8152903 100644
--- a/cmd/gorelease/gorelease.go
+++ b/cmd/gorelease/gorelease.go
@@ -85,6 +85,7 @@
 	"errors"
 	"flag"
 	"fmt"
+	"go/build"
 	"io"
 	"io/ioutil"
 	"log"
@@ -371,7 +372,7 @@
 			err = fmt.Errorf("removing temporary module directory: %v", rerr)
 		}
 	}()
-	tmpLoadDir, tmpGoModData, tmpGoSumData, err := prepareLoadDir(m.goModFile, m.modPath, tmpModRoot, version, false)
+	tmpLoadDir, tmpGoModData, tmpGoSumData, pkgPaths, prepareDiagnostics, err := prepareLoadDir(m.goModFile, m.modPath, tmpModRoot, version, false)
 	if err != nil {
 		return moduleInfo{}, err
 	}
@@ -380,11 +381,14 @@
 			err = fmt.Errorf("removing temporary load directory: %v", rerr)
 		}
 	}()
+
 	var loadDiagnostics []string
-	m.pkgs, loadDiagnostics, err = loadPackages(m.modPath, tmpModRoot, tmpLoadDir, tmpGoModData, tmpGoSumData)
+	m.pkgs, loadDiagnostics, err = loadPackages(m.modPath, tmpModRoot, tmpLoadDir, tmpGoModData, tmpGoSumData, pkgPaths)
 	if err != nil {
 		return moduleInfo{}, err
 	}
+
+	m.diagnostics = append(m.diagnostics, prepareDiagnostics...)
 	m.diagnostics = append(m.diagnostics, loadDiagnostics...)
 
 	return m, nil
@@ -476,7 +480,7 @@
 	m.modPath = m.goModFile.Module.Mod.Path
 
 	// Load packages.
-	tmpLoadDir, tmpGoModData, tmpGoSumData, err := prepareLoadDir(nil, m.modPath, m.modRoot, m.version, true)
+	tmpLoadDir, tmpGoModData, tmpGoSumData, pkgPaths, _, err := prepareLoadDir(nil, m.modPath, m.modRoot, m.version, true)
 	if err != nil {
 		return moduleInfo{}, err
 	}
@@ -485,7 +489,8 @@
 			err = fmt.Errorf("removing temporary load directory: %v", err)
 		}
 	}()
-	if m.pkgs, _, err = loadPackages(m.modPath, m.modRoot, tmpLoadDir, tmpGoModData, tmpGoSumData); err != nil {
+
+	if m.pkgs, _, err = loadPackages(m.modPath, m.modRoot, tmpLoadDir, tmpGoModData, tmpGoSumData, pkgPaths); err != nil {
 		return moduleInfo{}, err
 	}
 
@@ -502,8 +507,8 @@
 func makeReleaseReport(base, release moduleInfo) (report, error) {
 	// 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.
+	// If we don't have a base version to compare against just check the new
+	// packages for errors.
 	shouldCompare := base.version != "none"
 	isInternal := func(modPath, pkgPath string) bool {
 		if !hasPathPrefix(pkgPath, modPath) {
@@ -876,7 +881,12 @@
 }
 
 // prepareLoadDir creates a temporary directory and a go.mod file that requires
-// the module being loaded. go.sum is copied if present.
+// the module being loaded. go.sum is copied if present. It also creates a .go
+// file that imports every package in the given modPath. This temporary module
+// is useful for two reasons. First, replace and exclude directives from the
+// target module aren't applied, so we have the same view as a dependent module.
+// Second, we can run commands like 'go get' without modifying the original
+// go.mod and go.sum files.
 //
 // modFile is the pre-parsed go.mod file. If non-nil, its requirements and
 // go version will be copied so that incomplete and out-of-date requirements
@@ -884,14 +894,40 @@
 //
 // modPath is the module's path.
 //
+// modRoot is the module's root directory.
+//
 // version is the version of the module being loaded. If must be canonical
 // for modules loaded from the cache. Otherwise, it may be empty (for example,
 // when no release version is proposed).
 //
 // cached indicates whether the module is being loaded from the module cache.
-// If true, the module can be referenced with a simple requirement.
-// If false, the module will be referenced with a local replace directive.
-func prepareLoadDir(modFile *modfile.File, modPath, modRoot, version string, cached bool) (dir string, goModData, goSumData []byte, err error) {
+// If cached is true, then the module lives in the cache at
+// $GOMODCACHE/$modPath@$version/. Its go.mod file is at
+// $GOMODCACHE/cache/download/$modPath/@v/$version.mod. It must be referenced
+// with a simple require. A replace directive won't work because it may not have
+// a go.mod file in modRoot.
+// If cached is false, then modRoot is somewhere outside the module cache
+// (ex /tmp). We'll reference it with a local replace directive. It must have a
+// go.mod file in modRoot.
+//
+// dir is the location of the temporary directory.
+//
+// goModData and goSumData are the contents of the go.mod and go.sum files,
+// respectively.
+//
+// pkgPaths are the import paths of the module being loaded, including the path
+// to any main packages (as if they were importable).
+func prepareLoadDir(modFile *modfile.File, modPath, modRoot, version string, cached bool) (dir string, goModData, goSumData []byte, pkgPaths []string, diagnostics []string, err error) {
+	defer func() {
+		if err != nil {
+			if cached {
+				err = fmt.Errorf("preparing to load packages for %s@%s: %w", modPath, version, err)
+			} else {
+				err = fmt.Errorf("preparing to load packages for %s: %w", modPath, err)
+			}
+		}
+	}()
+
 	if module.Check(modPath, version) != nil {
 		// If no version is proposed or if the version isn't valid, use a fake
 		// version that matches the module's major version suffix. If the version
@@ -904,7 +940,7 @@
 
 	dir, err = ioutil.TempDir("", "gorelease-load")
 	if err != nil {
-		return "", nil, nil, err
+		return "", nil, nil, nil, nil, err
 	}
 
 	f := &modfile.File{}
@@ -923,21 +959,157 @@
 	}
 	goModData, err = f.Format()
 	if err != nil {
-		return "", nil, nil, err
+		return "", nil, nil, nil, nil, err
 	}
 	if err := ioutil.WriteFile(filepath.Join(dir, "go.mod"), goModData, 0666); err != nil {
-		return "", nil, nil, err
+		return "", nil, nil, nil, nil, err
 	}
 
 	goSumData, err = ioutil.ReadFile(filepath.Join(modRoot, "go.sum"))
 	if err != nil && !os.IsNotExist(err) {
-		return "", nil, nil, err
+		return "", nil, nil, nil, nil, err
 	}
 	if err := ioutil.WriteFile(filepath.Join(dir, "go.sum"), goSumData, 0666); err != nil {
-		return "", nil, nil, err
+		return "", nil, nil, nil, nil, err
 	}
 
-	return dir, goModData, goSumData, nil
+	// Add a .go file with requirements, so that `go get` won't blat
+	// requirements.
+	fakeImports := &strings.Builder{}
+	fmt.Fprint(fakeImports, "package tmp\n")
+	imps, err := collectImportPaths(modPath, modRoot)
+	if err != nil {
+		return "", nil, nil, nil, nil, err
+	}
+	for _, imp := range imps {
+		fmt.Fprintf(fakeImports, "import _ %q\n", imp)
+	}
+	if err := ioutil.WriteFile(filepath.Join(dir, "tmp.go"), []byte(fakeImports.String()), 0666); err != nil {
+		return "", nil, nil, nil, nil, err
+	}
+
+	// Add missing requirements.
+	cmd := exec.Command("go", "get", "-d", ".")
+	cmd.Dir = dir
+	if _, err := cmd.Output(); err != nil {
+		return "", nil, nil, nil, nil, fmt.Errorf("looking for missing dependencies: %w", cleanCmdError(err))
+	}
+
+	// Report new requirements in go.mod.
+	goModPath := filepath.Join(dir, "go.mod")
+	loadReqs := func(data []byte) ([]string, error) {
+		modFile, err := modfile.ParseLax(goModPath, data, nil)
+		if err != nil {
+			return nil, err
+		}
+		lines := make([]string, len(modFile.Require))
+		for i, req := range modFile.Require {
+			lines[i] = req.Mod.String()
+		}
+		sort.Strings(lines)
+		return lines, nil
+	}
+
+	oldReqs, err := loadReqs(goModData)
+	if err != nil {
+		return "", nil, nil, nil, nil, err
+	}
+	newGoModData, err := ioutil.ReadFile(goModPath)
+	if err != nil {
+		return "", nil, nil, nil, nil, err
+	}
+	newReqs, err := loadReqs(newGoModData)
+	if err != nil {
+		return "", nil, nil, nil, nil, err
+	}
+
+	oldMap := make(map[string]bool)
+	for _, req := range oldReqs {
+		oldMap[req] = true
+	}
+	var missing []string
+	for _, req := range newReqs {
+		if !oldMap[req] {
+			missing = append(missing, req)
+		}
+	}
+
+	if len(missing) > 0 {
+		diagnostics = append(diagnostics, fmt.Sprintf("go.mod: the following requirements are needed\n\t%s\nRun 'go mod tidy' to add missing requirements.", strings.Join(missing, "\n\t")))
+		return dir, goModData, goSumData, imps, diagnostics, nil
+	}
+
+	// Cached modules may have no go.sum.
+	// We skip comparison because a downloaded module is outside the user's
+	// control.
+	if !cached {
+		// Check if 'go get' added new hashes to go.sum.
+		goSumPath := filepath.Join(dir, "go.sum")
+		newGoSumData, err := ioutil.ReadFile(goSumPath)
+		if err != nil {
+			if !os.IsNotExist(err) {
+				return "", nil, nil, nil, nil, err
+			}
+			// If the sum doesn't exist, that's ok: we'll treat "no go.sum" like
+			// "empty go.sum".
+		}
+
+		if bytes.Compare(goSumData, newGoSumData) != 0 {
+			diagnostics = append(diagnostics, "go.sum: one or more sums are missing. Run 'go mod tidy' to add missing sums.")
+		}
+	}
+
+	return dir, goModData, goSumData, imps, diagnostics, nil
+}
+
+// collectImportPaths visits the given root and traverses its directories
+// recursively, collecting the import paths of all importable packages in each
+// directory along the way.
+//
+// modPath is the module path.
+// root is the root directory of the module to collect imports for (the root
+// of the modPath module).
+//
+// Note: the returned importPaths will include main if it exists in root.
+//
+// TODO(deklerk): Stop at any discovered submodule. Make sure a test exists that
+// asserts that behavior.
+func collectImportPaths(modPath, root string) (importPaths []string, _ error) {
+	err := filepath.Walk(root, func(walkPath string, fi os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+
+		// Avoid .foo, _foo, and testdata subdirectory trees.
+		if !fi.IsDir() {
+			return nil
+		}
+		base := filepath.Base(walkPath)
+		if strings.HasPrefix(base, ".") || strings.HasPrefix(base, "_") || base == "testdata" || base == "internal" {
+			return filepath.SkipDir
+		}
+
+		p, err := build.Default.ImportDir(walkPath, 0)
+		if err != nil {
+			if nogoErr := (*build.NoGoError)(nil); errors.As(err, &nogoErr) {
+				// No .go files found in directory. That's ok, we'll keep
+				// searching.
+				return nil
+			}
+			return err
+		}
+
+		// Construct the import path.
+		importPath := path.Join(modPath, filepath.ToSlash(trimFilePathPrefix(p.Dir, root)))
+		importPaths = append(importPaths, importPath)
+
+		return nil
+	})
+	if err != nil {
+		return nil, fmt.Errorf("listing packages in %s: %v", root, err)
+	}
+
+	return importPaths, nil
 }
 
 // loadPackages returns a list of all packages in the module modPath, sorted by
@@ -954,40 +1126,7 @@
 // returned through diagnostics.
 // err will be non-nil in case of a fatal error that prevented packages
 // from being loaded.
-func loadPackages(modPath, modRoot, loadDir string, goModData, goSumData []byte) (pkgs []*packages.Package, diagnostics []string, err error) {
-	// List packages in the module.
-	// We can't just load example.com/mod/... because that might include packages
-	// in nested modules. We also can't filter packages from the output of
-	// packages.Load, since it doesn't tell us which module they came from.
-	//
-	// TODO(golang.org/issue/41456): this command fails in -mod=readonly mode
-	// if sums are missing, which they always are for downloaded modules. In
-	// Go 1.16, -mod=readonly is the default, and -mod=mod may eventually be
-	// removed, so we should avoid -mod=mod here. Lazy loading may also require
-	// changes to temporary module requirements.
-	//
-	// Instead of running this command, we should make a list of importable
-	// packages by walking the directory tree. With such a list,
-	// in prepareLoadDir, we could generate a temporary package that imports
-	// all of them, then 'go get -d' that package to ensure no requirements
-	// or sums are missing.
-	format := fmt.Sprintf(`{{if .Module}}{{if eq .Module.Path %q}}{{.ImportPath}}{{end}}{{end}}`, modPath)
-	cmd := exec.Command("go", "list", "-mod=mod", "-e", "-f", format, "--", modPath+"/...")
-	cmd.Dir = loadDir
-	out, err := cmd.Output()
-	if err != nil {
-		return nil, nil, cleanCmdError(err)
-	}
-	var pkgPaths []string
-	for len(out) > 0 {
-		eol := bytes.IndexByte(out, '\n')
-		if eol < 0 {
-			eol = len(out)
-		}
-		pkgPaths = append(pkgPaths, string(out[:eol]))
-		out = out[eol+1:]
-	}
-
+func loadPackages(modPath, modRoot, loadDir string, goModData, goSumData []byte, pkgPaths []string) (pkgs []*packages.Package, diagnostics []string, err error) {
 	// Load packages.
 	// TODO(jayconrod): if there are errors loading packages in the release
 	// version, try loading in the release directory. Errors there would imply
@@ -1017,58 +1156,6 @@
 		}
 	}
 
-	// Report new requirements in go.mod.
-	goModPath := filepath.Join(loadDir, "go.mod")
-	loadReqs := func(data []byte) ([]string, error) {
-		modFile, err := modfile.ParseLax(goModPath, data, nil)
-		if err != nil {
-			return nil, err
-		}
-		lines := make([]string, len(modFile.Require))
-		for i, req := range modFile.Require {
-			lines[i] = req.Mod.String()
-		}
-		sort.Strings(lines)
-		return lines, nil
-	}
-
-	oldReqs, err := loadReqs(goModData)
-	if err != nil {
-		return nil, nil, err
-	}
-	newGoModData, err := ioutil.ReadFile(goModPath)
-	if err != nil {
-		return nil, nil, err
-	}
-	newReqs, err := loadReqs(newGoModData)
-	if err != nil {
-		return nil, nil, err
-	}
-
-	oldMap := make(map[string]bool)
-	for _, req := range oldReqs {
-		oldMap[req] = true
-	}
-	var missing []string
-	for _, req := range newReqs {
-		if !oldMap[req] {
-			missing = append(missing, req)
-		}
-	}
-
-	if len(missing) > 0 {
-		diagnostics = append(diagnostics, fmt.Sprintf("go.mod: the following requirements are needed\n\t%s\nRun 'go mod tidy' to add missing requirements.", strings.Join(missing, "\n\t")))
-		return pkgs, diagnostics, nil
-	}
-
-	newGoSumData, err := ioutil.ReadFile(filepath.Join(loadDir, "go.sum"))
-	if err != nil && !os.IsNotExist(err) {
-		return nil, nil, err
-	}
-	if !bytes.Equal(goSumData, newGoSumData) {
-		diagnostics = append(diagnostics, "go.sum: one or more sums are missing.\nRun 'go mod tidy' to add missing sums.")
-	}
-
 	return pkgs, diagnostics, nil
 }
 
diff --git a/cmd/gorelease/path.go b/cmd/gorelease/path.go
index 53bb099..bfe1f20 100644
--- a/cmd/gorelease/path.go
+++ b/cmd/gorelease/path.go
@@ -52,10 +52,30 @@
 	}
 }
 
+// trimFilePathPrefix returns the given filesystem path s without the leading
+// prefix.
+func trimFilePathPrefix(s, prefix string) string {
+	sv := strings.ToUpper(filepath.VolumeName(s))
+	pv := strings.ToUpper(filepath.VolumeName(prefix))
+	s = s[len(sv):]
+	prefix = prefix[len(pv):]
+
+	if !hasFilePathPrefix(s, prefix) || len(prefix) == 0 {
+		return s
+	}
+	if len(s) == len(prefix) {
+		return ""
+	}
+	if prefix[len(prefix)-1] == filepath.Separator {
+		return strings.TrimPrefix(s, prefix)
+	}
+	return s[len(prefix)+1:]
+}
+
 // trimPathPrefix returns p without the leading prefix. Unlike
-// strings.TrimPrefix, the prefix will only match on slash-separted comopnent
+// strings.TrimPrefix, the prefix will only match on slash-separted component
 // boundaries, so trimPathPrefix("aa/b", "aa") returns "b", but
-// trimPathPrefix("aa/b", "a") retunrs "aa/b".
+// trimPathPrefix("aa/b", "a") returns "aa/b".
 func trimPathPrefix(p, prefix string) string {
 	if prefix == "" {
 		return p
diff --git a/cmd/gorelease/path_test.go b/cmd/gorelease/path_test.go
index 48c30d5..57dfd98 100644
--- a/cmd/gorelease/path_test.go
+++ b/cmd/gorelease/path_test.go
@@ -113,6 +113,80 @@
 	}
 }
 
+func TestTrimFilePathPrefix(t *testing.T) {
+	type test struct {
+		desc, path, prefix, want string
+	}
+	var tests []test
+	if runtime.GOOS == "windows" {
+		tests = []test{
+			// Note: these two cases in which the result preserves the leading \
+			// don't come up in reality in gorelease. That's because prefix is
+			// always far to the right of the path parts (ex github.com/foo/bar
+			// in C:\Users\foo\AppData\Local\Temp\...\github.com\foo\bar).
+			{
+				desc:   "empty_prefix",
+				path:   `c:\a\b`,
+				prefix: "",
+				want:   `\a\b`,
+			}, {
+				desc:   "partial_component",
+				path:   `c:\aa\b`,
+				prefix: `c:\a`,
+				want:   `\aa\b`,
+			},
+
+			{
+				desc:   "drive_prefix",
+				path:   `c:\a\b`,
+				prefix: `c:\`,
+				want:   `a\b`,
+			}, {
+				desc:   "partial_prefix",
+				path:   `c:\a\b`,
+				prefix: `c:\a`,
+				want:   `b`,
+			}, {
+				desc:   "full_prefix",
+				path:   `c:\a\b`,
+				prefix: `c:\a\b`,
+				want:   "",
+			},
+		}
+	} else {
+		tests = []test{
+			{
+				desc:   "empty_prefix",
+				path:   "/a/b",
+				prefix: "",
+				want:   "/a/b",
+			}, {
+				desc:   "partial_prefix",
+				path:   "/a/b",
+				prefix: "/a",
+				want:   "b",
+			}, {
+				desc:   "full_prefix",
+				path:   "/a/b",
+				prefix: "/a/b",
+				want:   "",
+			}, {
+				desc:   "partial_component",
+				path:   "/aa/b",
+				prefix: "/a",
+				want:   "/aa/b",
+			},
+		}
+	}
+	for _, test := range tests {
+		t.Run(test.desc, func(t *testing.T) {
+			if got := trimFilePathPrefix(test.path, test.prefix); got != test.want {
+				t.Errorf("hasFilePathPrefix(%q, %q): got %v, want %v", test.path, test.prefix, got, test.want)
+			}
+		})
+	}
+}
+
 func TestTrimPathPrefix(t *testing.T) {
 	for _, test := range []struct {
 		desc, path, prefix, want string
diff --git a/cmd/gorelease/testdata/tidy/empty_sum.test b/cmd/gorelease/testdata/tidy/empty_sum.test
index 3cfeeac..4aa31d9 100644
--- a/cmd/gorelease/testdata/tidy/empty_sum.test
+++ b/cmd/gorelease/testdata/tidy/empty_sum.test
@@ -1,9 +1,8 @@
 mod=example.com/tidy
 base=v0.0.1
-success=0
+success=false
 -- want --
-go.sum: one or more sums are missing.
-Run 'go mod tidy' to add missing sums.
+go.sum: one or more sums are missing. Run 'go mod tidy' to add missing sums.
 -- go.mod --
 module example.com/tidy
 
diff --git a/cmd/gorelease/testdata/tidy/missing_req.test b/cmd/gorelease/testdata/tidy/missing_req_basic.test
similarity index 100%
rename from cmd/gorelease/testdata/tidy/missing_req.test
rename to cmd/gorelease/testdata/tidy/missing_req_basic.test
diff --git a/cmd/gorelease/testdata/tidy/missing_req_nested.test b/cmd/gorelease/testdata/tidy/missing_req_nested.test
new file mode 100644
index 0000000..57e3ff9
--- /dev/null
+++ b/cmd/gorelease/testdata/tidy/missing_req_nested.test
@@ -0,0 +1,26 @@
+mod=example.com/tidy
+base=v0.0.1
+success=false
+-- want --
+example.com/tidy/subdir
+-----------------------
+Compatible changes:
+- package added
+
+go.mod: the following requirements are needed
+	example.com/basic@v1.1.2
+Run 'go mod tidy' to add missing requirements.
+-- go.mod --
+module example.com/tidy
+
+go 1.12
+-- go.mod --
+module example.com/tidy
+
+go 1.12
+-- tidy.go --
+package tidy
+-- subdir/tidy.go --
+package subpkg
+
+import _ "example.com/basic/a"
diff --git a/cmd/gorelease/testdata/tidy/missing_req_twice_nested.test b/cmd/gorelease/testdata/tidy/missing_req_twice_nested.test
new file mode 100644
index 0000000..ab0dd9c
--- /dev/null
+++ b/cmd/gorelease/testdata/tidy/missing_req_twice_nested.test
@@ -0,0 +1,26 @@
+mod=example.com/tidy
+base=v0.0.1
+success=false
+-- want --
+example.com/tidy/subdir/subsubdir
+---------------------------------
+Compatible changes:
+- package added
+
+go.mod: the following requirements are needed
+	example.com/basic@v1.1.2
+Run 'go mod tidy' to add missing requirements.
+-- go.mod --
+module example.com/tidy
+
+go 1.12
+-- go.mod --
+module example.com/tidy
+
+go 1.12
+-- tidy.go --
+package tidy
+-- subdir/subsubdir/tidy.go --
+package subpkg
+
+import _ "example.com/basic/a"
diff --git a/cmd/gorelease/testdata/tidy/no_sum.test b/cmd/gorelease/testdata/tidy/no_sum.test
index 58ce747..8c594f5 100644
--- a/cmd/gorelease/testdata/tidy/no_sum.test
+++ b/cmd/gorelease/testdata/tidy/no_sum.test
@@ -1,9 +1,8 @@
 mod=example.com/tidy
 base=v0.0.1
-success=0
+success=false
 -- want --
-go.sum: one or more sums are missing.
-Run 'go mod tidy' to add missing sums.
+go.sum: one or more sums are missing. Run 'go mod tidy' to add missing sums.
 -- go.mod --
 module example.com/tidy