cmd/go: implement GOTOOLCHAIN=auto

The documentation is yet to be written (more work in the go
command remains first). This CL implements the toolchain
selection described in
https://go.dev/design/57001-gotoolchain#the-and-lines-in-in-the-work-module
with these changes based on the issue discussion:

1. GOTOOLCHAIN=auto looks for a go1.19.1 binary in $PATH
and if found uses it instead of downloading Go 1.19.1 as a module.

2. GOTOOLCHAIN=path is like GOTOOLCHAIN=auto, with
downloading disabled.

3. GOTOOLCHAIN=auto+version and GOTOOLCHAIN=path+version
set a different minimum version of Go to use during the version
selection. The default is to use the newer of what's on the go line
or the current toolchain. If you are have Go 1.22 installed locally
and want to switch to a minimum of Go 1.25 with go.mod files
allowed to bump even further, you would set GOTOOLCHAIN=auto+go1.25.
The minimum is also important when there is no go.mod involved,
such as when you write a tiny x.go program and run "go run x.go".
That would get Go 1.25 in this example, instead of falling back to
the local Go 1.22.

Change-Id: I286625a24420424c313d1082b9949a463b2fe14a
Reviewed-on: https://go-review.googlesource.com/c/go/+/494436
Run-TryBot: Russ Cox <rsc@golang.org>
TryBot-Result: Gopher Robot <gobot@golang.org>
Reviewed-by: Bryan Mills <bcmills@google.com>
Auto-Submit: Russ Cox <rsc@golang.org>
This commit is contained in:
Russ Cox 2023-05-11 14:32:56 -04:00 committed by Gopher Robot
parent aeb0644bd3
commit 9c447b7cf6
6 changed files with 390 additions and 28 deletions

View File

@ -7,10 +7,12 @@
package main
import (
"bytes"
"cmd/go/internal/base"
"cmd/go/internal/cfg"
"cmd/go/internal/modcmd"
"cmd/go/internal/modload"
"cmd/go/internal/work"
"context"
"fmt"
"internal/godebug"
@ -61,19 +63,36 @@ func switchGoToolchain() {
gotoolchain := cfg.Getenv("GOTOOLCHAIN")
if gotoolchain == "" {
if strings.HasPrefix(runtime.Version(), "go") {
gotoolchain = "local" // TODO: set to "auto" once auto is implemented below
} else {
gotoolchain = "local"
}
}
env := gotoolchain
if gotoolchain == "auto" || gotoolchain == "path" {
// TODO: Locate and read go.mod or go.work.
base.Fatalf("GOTOOLCHAIN=auto not yet implemented")
gotoolchain = "auto"
}
if gotoolchain == "local" || gotoolchain == runtime.Version() {
gotoolchain, min, haveMin := strings.Cut(gotoolchain, "+")
if haveMin {
if gotoolchain != "auto" && gotoolchain != "path" {
base.Fatalf("invalid GOTOOLCHAIN %q: only auto and path can use +version", gotoolchain)
}
if !strings.HasPrefix(min, "go1") {
base.Fatalf("invalid GOTOOLCHAIN %q: invalid minimum version %q", gotoolchain, min)
}
} else {
min = work.RuntimeVersion
}
pathOnly := gotoolchain == "path"
if gotoolchain == "auto" || gotoolchain == "path" {
// Locate and read go.mod or go.work.
goVers, toolchain := modGoToolchain()
if toolchain != "" {
// toolchain line wins by itself
gotoolchain = toolchain
} else if goVers != "" {
gotoolchain = toolchainMax(min, "go"+goVers)
} else {
gotoolchain = min
}
}
if gotoolchain == "local" || gotoolchain == work.RuntimeVersion {
// Let the current binary handle the command.
return
}
@ -95,7 +114,7 @@ func switchGoToolchain() {
// GOTOOLCHAIN=auto looks in PATH and then falls back to download.
// GOTOOLCHAIN=path only looks in PATH.
if env == "path" {
if pathOnly {
base.Fatalf("cannot find %q in PATH", gotoolchain)
}
@ -208,3 +227,39 @@ func execGoToolchain(gotoolchain, dir, exe string) {
err := syscall.Exec(exe, os.Args, os.Environ())
base.Fatalf("exec %s: %v", gotoolchain, err)
}
// modGoToolchain finds the enclosing go.work or go.mod file
// and returns the go version and toolchain lines from the file.
// The toolchain line overrides the version line
func modGoToolchain() (goVers, toolchain string) {
wd := base.UncachedCwd()
file := modload.FindGoWork(wd)
// $GOWORK can be set to a file that does not yet exist, if we are running 'go work init'.
// Do not try to load the file in that case
if _, err := os.Stat(file); err != nil {
file = ""
}
if file == "" {
file = modload.FindGoMod(wd)
}
if file == "" {
return "", ""
}
data, err := os.ReadFile(file)
if err != nil {
base.Fatalf("%v", err)
}
for len(data) > 0 {
var line []byte
line, data, _ = bytes.Cut(data, nl)
line = bytes.TrimSpace(line)
if goVers == "" {
goVers = parseKey(line, goKey)
}
if toolchain == "" {
toolchain = parseKey(line, toolchainKey)
}
}
return
}

View File

@ -0,0 +1,117 @@
// Copyright 2023 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
import (
"bytes"
"strings"
)
var (
nl = []byte("\n")
comment = []byte("//")
goKey = []byte("go")
toolchainKey = []byte("toolchain")
)
// parseKey checks whether line begings with key ("go" or "toolchain").
// If so, it returns the remainder of the line (the argument).
func parseKey(line, key []byte) string {
if !bytes.HasPrefix(line, key) {
return ""
}
line = bytes.TrimPrefix(line, key)
if len(line) == 0 || (line[0] != ' ' && line[0] != '\t') {
return ""
}
line, _, _ = bytes.Cut(line, comment) // strip comments
return string(bytes.TrimSpace(line))
}
// toolchainMax returns the max of x and y as toolchain names
// like go1.19.4, comparing the versions.
func toolchainMax(x, y string) string {
if toolchainCmp(x, y) >= 0 {
return x
}
return y
}
// toolchainCmp returns -1, 0, or +1 depending on whether
// x < y, x == y, or x > y, interpreted as toolchain versions.
func toolchainCmp(x, y string) int {
if x == y {
return 0
}
if y == "" {
return +1
}
if x == "" {
return -1
}
if !strings.HasPrefix(x, "go1") && !strings.HasPrefix(y, "go1") {
return 0
}
if !strings.HasPrefix(x, "go1") {
return +1
}
if !strings.HasPrefix(y, "go1") {
return -1
}
x = strings.TrimPrefix(x, "go")
y = strings.TrimPrefix(y, "go")
for x != "" || y != "" {
if x == y {
return 0
}
xN, xRest := versionCut(x)
yN, yRest := versionCut(y)
if xN > yN {
return +1
}
if xN < yN {
return -1
}
x = xRest
y = yRest
}
return 0
}
// versionCut cuts the version x after the next dot or before the next non-digit,
// returning the leading decimal found and the remainder of the string.
func versionCut(x string) (int, string) {
// Treat empty string as infinite source of .0.0.0...
if x == "" {
return 0, ""
}
i := 0
v := 0
for i < len(x) && '0' <= x[i] && x[i] <= '9' {
v = v*10 + int(x[i]-'0')
i++
}
// Treat non-empty non-number as -1 (for release candidates, etc),
// but stop at next number.
if i == 0 {
for i < len(x) && (x[i] < '0' || '9' < x[i]) {
i++
}
if i < len(x) && x[i] == '.' {
i++
}
if strings.Contains(x[:i], "alpha") {
return -3, x[i:]
}
if strings.Contains(x[:i], "beta") {
return -2, x[i:]
}
return -1, x[i:]
}
if i < len(x) && x[i] == '.' {
i++
}
return v, x[i:]
}

View File

@ -0,0 +1,49 @@
// Copyright 2023 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
import "testing"
var toolchainCmpTests = []struct {
x string
y string
out int
}{
{"", "", 0},
{"x", "x", 0},
{"", "x", -1},
{"go1.5", "go1.6", -1},
{"go1.5", "go1.10", -1},
{"go1.6", "go1.6.1", -1},
{"go1.999", "devel go1.4", -1},
{"devel go1.5", "devel go1.6", 0}, // devels are all +infinity
{"go1.19", "go1.19.1", -1},
{"go1.19rc1", "go1.19", -1},
{"go1.19rc1", "go1.19.1", -1},
{"go1.19rc1", "go1.19rc2", -1},
{"go1.19.0", "go1.19.1", -1},
{"go1.19rc1", "go1.19.0", -1},
{"go1.19alpha3", "go1.19beta2", -1},
{"go1.19beta2", "go1.19rc1", -1},
// Syntax we don't ever plan to use, but just in case we do.
{"go1.19.0-rc.1", "go1.19.0-rc.2", -1},
{"go1.19.0-rc.1", "go1.19.0", -1},
{"go1.19.0-alpha.3", "go1.19.0-beta.2", -1},
{"go1.19.0-beta.2", "go1.19.0-rc.1", -1},
}
func TestToolchainCmp(t *testing.T) {
for _, tt := range toolchainCmpTests {
out := toolchainCmp(tt.x, tt.y)
if out != tt.out {
t.Errorf("toolchainCmp(%q, %q) = %d, want %d", tt.x, tt.y, out, tt.out)
}
out = toolchainCmp(tt.y, tt.x)
if out != -tt.out {
t.Errorf("toolchainCmp(%q, %q) = %d, want %d", tt.y, tt.x, out, -tt.out)
}
}
}

View File

@ -15,14 +15,22 @@ import (
var cwd string
var cwdOnce sync.Once
// UncachedCwd returns the current working directory.
// Most callers should use Cwd, which caches the result for future use.
// UncachedCwd is appropriate to call early in program startup before flag parsing,
// because the -C flag may change the current directory.
func UncachedCwd() string {
wd, err := os.Getwd()
if err != nil {
Fatalf("cannot determine current directory: %v", err)
}
return wd
}
// Cwd returns the current working directory at the time of the first call.
func Cwd() string {
cwdOnce.Do(func() {
var err error
cwd, err = os.Getwd()
if err != nil {
Fatalf("cannot determine current directory: %v", err)
}
cwd = UncachedCwd()
})
return cwd
}

View File

@ -292,21 +292,29 @@ func BinDir() string {
// operate in workspace mode. It should not be called by other commands,
// for example 'go mod tidy', that don't operate in workspace mode.
func InitWorkfile() {
workFilePath = FindGoWork(base.Cwd())
}
// FindGoWork returns the name of the go.work file for this command,
// or the empty string if there isn't one.
// Most code should use Init and Enabled rather than use this directly.
// It is exported mainly for Go toolchain switching, which must process
// the go.work very early at startup.
func FindGoWork(wd string) string {
if RootMode == NoRoot {
workFilePath = ""
return
return ""
}
switch gowork := cfg.Getenv("GOWORK"); gowork {
case "off":
workFilePath = ""
return ""
case "", "auto":
workFilePath = findWorkspaceFile(base.Cwd())
return findWorkspaceFile(wd)
default:
if !filepath.IsAbs(gowork) {
base.Fatalf("the path provided to GOWORK must be an absolute path")
base.Fatalf("go: invalid GOWORK: not an absolute path")
}
workFilePath = gowork
return gowork
}
}
@ -467,19 +475,30 @@ func WillBeEnabled() bool {
return false
}
if modRoot := findModuleRoot(base.Cwd()); modRoot == "" {
return FindGoMod(base.Cwd()) != ""
}
// FindGoMod returns the name of the go.mod file for this command,
// or the empty string if there isn't one.
// Most code should use Init and Enabled rather than use this directly.
// It is exported mainly for Go toolchain switching, which must process
// the go.mod very early at startup.
func FindGoMod(wd string) string {
modRoot := findModuleRoot(wd)
if modRoot == "" {
// GO111MODULE is 'auto', and we can't find a module root.
// Stay in GOPATH mode.
return false
} else if search.InDir(modRoot, os.TempDir()) == "." {
return ""
}
if search.InDir(modRoot, os.TempDir()) == "." {
// If you create /tmp/go.mod for experimenting,
// then any tests that create work directories under /tmp
// will find it and get modules when they're not expecting them.
// It's a bit of a peculiar thing to disallow but quite mysterious
// when it happens. See golang.org/issue/26708.
return false
return ""
}
return true
return filepath.Join(modRoot, "go.mod")
}
// Enabled reports whether modules are (or must be) enabled.

View File

@ -32,6 +32,120 @@ env GOTOOLCHAIN=go1.999testmod
go version
stderr 'go: downloading go1.999testmod \(.*/.*\)'
# GOTOOLCHAIN=auto
env GOTOOLCHAIN=auto
env TESTGO_VERSION=go1.100 # set TESTGO_VERSION because devel is newer than everything
# toolchain line in go.mod
cp go119toolchain1999 go.mod
go version
stdout go1.999
# toolchain line in go.work
cp empty go.mod
cp go119toolchain1999 go.work
go version
stdout go1.999
rm go.work
# go version in go.mod
cp go1999 go.mod
go version
stdout go1.999
# go version in go.work
cp empty go.mod
cp go1999 go.work
go version
stdout go1.999
rm go.work
# GOTOOLCHAIN=auto falls back to local toolchain if newer than go line
env TESTGO_VERSION=go1.1000
# toolchain line in go.mod
cp go119toolchain1999 go.mod
go version
stdout go1.999
# toolchain line in go.work
cp empty go.mod
cp go119toolchain1999 go.work
go version
stdout go1.999
rm go.work
# go version in go.mod
cp go1999 go.mod
go version
! stdout go1.999
# go version in go.work
cp empty go.mod
cp go1999 go.work
go version
! stdout go1.999
rm go.work
# GOTOOLCHAIN=auto+go1.1000 falls back to go1.1000 if newer than go line
env TESTGO_VERSION=go1.1
env GOTOOLCHAIN=auto+go1.1000
# toolchain line in go.mod
cp go119toolchain1999 go.mod
go version
stdout go1.999
# toolchain line in go.work
cp empty go.mod
cp go119toolchain1999 go.work
go version
stdout go1.999
rm go.work
# go version in go.mod
cp go1999 go.mod
! go version
stderr go1.1000
# go version in go.work
cp empty go.mod
cp go1999 go.work
! go version
stderr go1.1000
rm go.work
# GOTOOLCHAIN=path refuses to download
env GOTOOLCHAIN=path
env TESTGO_VERSION=go1.19
cp go1999 go.mod
go version
stdout go1.999
cp go1999mod go.mod
! go version
stderr '^go: cannot find "go1.999mod" in PATH$'
-- empty --
-- go1999 --
go 1.999testpath
-- go1999mod --
go 1.999mod
-- go119 ---
go 1.19
-- go119toolchain1999 --
go 1.19
toolchain go1.999testpath
-- go1999toolchain119 --
go 1.999testpath
toolchain go1.19
-- go1.999testpath.go --
package main