From 6c16b44ef0251921768d736b12308d38af255d31 Mon Sep 17 00:00:00 2001 From: Blue Thunder Somogyi Date: Thu, 26 Mar 2026 23:58:18 -0400 Subject: [PATCH] Add Go workspace (go.work) support for multi-module monorepos Detect and parse go.work files to resolve packages across all workspace modules, enabling cross-module dependency tracking in CI pipelines. Add -no-workspace flag to opt out. Mark all packages dirty when go.work or go.mod changes. Co-authored by: Blue Thunder Somogyi --- cmd/gta/main.go | 2 + gta.go | 95 +++++- gta_test.go | 2 +- options.go | 10 + packager.go | 12 +- testdata/workspacetest/go.work | 7 + testdata/workspacetest/modA/go.mod | 5 + testdata/workspacetest/modA/pkg/a.go | 5 + testdata/workspacetest/modB/go.mod | 3 + testdata/workspacetest/modB/internal/util.go | 3 + testdata/workspacetest/modB/pkg/b.go | 3 + testdata/workspacetest/modC/go.mod | 5 + testdata/workspacetest/modC/pkg/c.go | 5 + testdata/workspacetest/modD/go.mod | 3 + testdata/workspacetest/modD/pkg/d.go | 3 + workspace_test.go | 340 +++++++++++++++++++ 16 files changed, 493 insertions(+), 10 deletions(-) create mode 100644 testdata/workspacetest/go.work create mode 100644 testdata/workspacetest/modA/go.mod create mode 100644 testdata/workspacetest/modA/pkg/a.go create mode 100644 testdata/workspacetest/modB/go.mod create mode 100644 testdata/workspacetest/modB/internal/util.go create mode 100644 testdata/workspacetest/modB/pkg/b.go create mode 100644 testdata/workspacetest/modC/go.mod create mode 100644 testdata/workspacetest/modC/pkg/c.go create mode 100644 testdata/workspacetest/modD/go.mod create mode 100644 testdata/workspacetest/modD/pkg/d.go create mode 100644 workspace_test.go diff --git a/cmd/gta/main.go b/cmd/gta/main.go index d37b4ba2..a6db4f32 100644 --- a/cmd/gta/main.go +++ b/cmd/gta/main.go @@ -35,6 +35,7 @@ func main() { flagChangedFiles := flag.String("changed-files", "", "path to a file containing a newline separated list of files that have changed") flagTags := flag.String("tags", "", "a list of build tags to consider") flagTestTransitive := flag.Bool("test-transitive", true, "legacy behavior; include transitive test dependencies in the reverse dependency graph traversal") + flagNoWorkspace := flag.Bool("no-workspace", false, "disable Go workspace (go.work) support; operate in single-module mode") flag.Parse() @@ -55,6 +56,7 @@ func main() { gta.SetPrefixes(parseStringSlice(*flagInclude)...), gta.SetTags(tags...), gta.SetIncludeTransitiveTestDeps(*flagTestTransitive), + gta.SetDisableWorkspace(*flagNoWorkspace), } if len(*flagChangedFiles) == 0 { diff --git a/gta.go b/gta.go index 60f69c4e..77d630f4 100644 --- a/gta.go +++ b/gta.go @@ -18,6 +18,8 @@ import ( "path/filepath" "sort" "strings" + + "golang.org/x/mod/modfile" ) var ( @@ -93,6 +95,7 @@ type GTA struct { tags []string roots []string includeTransitiveTestDeps bool + disableWorkspace bool } // New returns a new GTA with various options passed to New. Options will be @@ -111,7 +114,7 @@ func New(opts ...Option) (*GTA, error) { } if gta.roots == nil { - roots, err := toplevel() + roots, err := toplevel(gta.disableWorkspace) if err != nil { return nil, fmt.Errorf("could not get top level directory") } @@ -137,7 +140,7 @@ func New(opts ...Option) (*GTA, error) { // when a file is changed. e.g. if a vendored file that is constrained to // Windows is changed, that package wouldn't load at all and trying to find // the package's dependencies would fail. - gta.packager = NewPackager(nil, gta.tags) + gta.packager = NewPackager(nil, gta.tags, gta.disableWorkspace) } return gta, nil @@ -260,6 +263,35 @@ func (g *GTA) markedPackages() (map[string]map[string]bool, error) { for abs, dir := range dirs { // TODO(bc): handle changes to go.mod when vendoring is not being used. + // When go.work or go.mod changes, the dependency graph may have changed + // significantly. Mark all resolvable packages that match our prefix + // filter as changed so their dependents will be re-evaluated. + hasModuleConfig := false + for _, f := range dir.Files { + if f == "go.work" || f == "go.mod" { + hasModuleConfig = true + break + } + } + if hasModuleConfig { + graph, err := g.packager.DependentGraph() + if err == nil { + for pkg := range graph.graph { + if !hasPrefixIn(pkg, g.prefixes) { + continue + } + if _, err := g.packager.PackageFromImport(pkg); err == nil { + changed[pkg] = false + } + } + } + // If this directory has no Go files (e.g. workspace root with + // only go.work), skip further package-level processing. + if !hasGoFile(dir.Files) { + continue + } + } + // Add packages that embed the files of dir. for _, f := range dir.Files { // An embedded file may: @@ -599,17 +631,67 @@ func hasOnlyTestFilenames(sl []string) bool { return true } -func toplevel() ([]string, error) { +func toplevel(disableWorkspace bool) ([]string, error) { if os.Getenv("GO111MODULE") == "off" { return gopaths() } - root, err := moduleroot() + if !disableWorkspace { + roots, err := workspaceroots() + if err != nil { + return nil, err + } + if roots != nil { + return roots, nil + } + } + + root, err := moduleroot(disableWorkspace) if err != nil { return nil, err } return []string{root}, nil +} +// workspaceroots detects whether the current directory is within a Go +// workspace (go.work) and returns the absolute paths of all workspace module +// directories. It returns nil, nil when not in workspace mode. +func workspaceroots() ([]string, error) { + cmd := exec.Command("go", "env", "GOWORK") + b, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("could not get GOWORK: %w", err) + } + gowork := strings.TrimSpace(string(b)) + if gowork == "" || gowork == "off" { + return nil, nil + } + + data, err := os.ReadFile(gowork) + if err != nil { + return nil, fmt.Errorf("could not read go.work file %q: %w", gowork, err) + } + + workFile, err := modfile.ParseWork(gowork, data, nil) + if err != nil { + return nil, fmt.Errorf("could not parse go.work file %q: %w", gowork, err) + } + + workDir := filepath.Dir(gowork) + var roots []string + for _, use := range workFile.Use { + absDir, err := filepath.Abs(filepath.Join(workDir, use.Path)) + if err != nil { + return nil, fmt.Errorf("could not resolve workspace module path %q: %w", use.Path, err) + } + roots = append(roots, absDir) + } + + if len(roots) == 0 { + return nil, nil + } + + return roots, nil } func gopaths() ([]string, error) { @@ -626,8 +708,11 @@ func gopaths() ([]string, error) { return roots, nil } -func moduleroot() (string, error) { +func moduleroot(disableWorkspace bool) (string, error) { cmd := exec.Command("go", "list", "-m", "-f", "{{.Dir}}") + if disableWorkspace { + cmd.Env = append(os.Environ(), "GOWORK=off") + } b, err := cmd.CombinedOutput() if err != nil { return "", fmt.Errorf("could get not get module root: %w", err) diff --git a/gta_test.go b/gta_test.go index 5ffc25c6..1c70d5f8 100644 --- a/gta_test.go +++ b/gta_test.go @@ -313,7 +313,7 @@ func TestGTA_ChangedPackages(t *testing.T) { popd := chdir(t, exporter.Filename(e, testModule, "")) t.Cleanup(popd) - cfg := newLoadConfig(nil) + cfg := newLoadConfig(nil, false) e.Config.Mode = cfg.Mode e.Config.BuildFlags = cfg.BuildFlags e.Config.Tests = cfg.Tests diff --git a/options.go b/options.go index 6e20bca8..7da3aa9e 100644 --- a/options.go +++ b/options.go @@ -51,3 +51,13 @@ func SetIncludeTransitiveTestDeps(include bool) Option { return nil } } + +// SetDisableWorkspace disables Go workspace (go.work) detection. When true, +// GTA operates in single-module mode even if a go.work file is present. +// This is equivalent to setting the GOWORK=off environment variable. +func SetDisableWorkspace(disable bool) Option { + return func(g *GTA) error { + g.disableWorkspace = disable + return nil + } +} diff --git a/packager.go b/packager.go index 9ddb325a..756ef243 100644 --- a/packager.go +++ b/packager.go @@ -60,9 +60,9 @@ type Packager interface { EmbeddedBy(string) []string } -func NewPackager(patterns, tags []string) Packager { +func NewPackager(patterns, tags []string, disableWorkspace bool) Packager { build.Default.BuildTags = tags - return newPackager(newLoadConfig(tags), build.Default, patterns) + return newPackager(newLoadConfig(tags, disableWorkspace), build.Default, patterns) } func newPackager(cfg *packages.Config, ctx build.Context, patterns []string) Packager { @@ -81,8 +81,8 @@ func newPackager(cfg *packages.Config, ctx build.Context, patterns []string) Pac // newLoadConfig returns a *packages.Config suitable for use by packages.Load. // The constructor here is mostly useful for tests. -func newLoadConfig(tags []string) *packages.Config { - return &packages.Config{ +func newLoadConfig(tags []string, disableWorkspace bool) *packages.Config { + cfg := &packages.Config{ Mode: packages.NeedName | packages.NeedFiles | packages.NeedEmbedFiles | @@ -95,6 +95,10 @@ func newLoadConfig(tags []string) *packages.Config { }, Tests: true, } + if disableWorkspace { + cfg.Env = append(os.Environ(), "GOWORK=off") + } + return cfg } // packageContext implements the Packager interface. diff --git a/testdata/workspacetest/go.work b/testdata/workspacetest/go.work new file mode 100644 index 00000000..7aec7463 --- /dev/null +++ b/testdata/workspacetest/go.work @@ -0,0 +1,7 @@ +go 1.25 + +use ( + ./modA + ./modB + ./modC +) diff --git a/testdata/workspacetest/modA/go.mod b/testdata/workspacetest/modA/go.mod new file mode 100644 index 00000000..309725dd --- /dev/null +++ b/testdata/workspacetest/modA/go.mod @@ -0,0 +1,5 @@ +module workspace.test/modA + +go 1.25 + +require workspace.test/modB v0.0.0 diff --git a/testdata/workspacetest/modA/pkg/a.go b/testdata/workspacetest/modA/pkg/a.go new file mode 100644 index 00000000..4588e7ca --- /dev/null +++ b/testdata/workspacetest/modA/pkg/a.go @@ -0,0 +1,5 @@ +package pkg + +import "workspace.test/modB/pkg" + +func UsesB() string { return pkg.Hello() } diff --git a/testdata/workspacetest/modB/go.mod b/testdata/workspacetest/modB/go.mod new file mode 100644 index 00000000..275e77bf --- /dev/null +++ b/testdata/workspacetest/modB/go.mod @@ -0,0 +1,3 @@ +module workspace.test/modB + +go 1.25 diff --git a/testdata/workspacetest/modB/internal/util.go b/testdata/workspacetest/modB/internal/util.go new file mode 100644 index 00000000..aa6f92ba --- /dev/null +++ b/testdata/workspacetest/modB/internal/util.go @@ -0,0 +1,3 @@ +package internal + +func Format(s string) string { return s } diff --git a/testdata/workspacetest/modB/pkg/b.go b/testdata/workspacetest/modB/pkg/b.go new file mode 100644 index 00000000..a1a1e33e --- /dev/null +++ b/testdata/workspacetest/modB/pkg/b.go @@ -0,0 +1,3 @@ +package pkg + +func Hello() string { return "hello" } diff --git a/testdata/workspacetest/modC/go.mod b/testdata/workspacetest/modC/go.mod new file mode 100644 index 00000000..82ca89f3 --- /dev/null +++ b/testdata/workspacetest/modC/go.mod @@ -0,0 +1,5 @@ +module workspace.test/modC + +go 1.25 + +require workspace.test/modA v0.0.0 diff --git a/testdata/workspacetest/modC/pkg/c.go b/testdata/workspacetest/modC/pkg/c.go new file mode 100644 index 00000000..6ee3874d --- /dev/null +++ b/testdata/workspacetest/modC/pkg/c.go @@ -0,0 +1,5 @@ +package pkg + +import "workspace.test/modA/pkg" + +func TransitiveUse() string { return pkg.UsesB() } diff --git a/testdata/workspacetest/modD/go.mod b/testdata/workspacetest/modD/go.mod new file mode 100644 index 00000000..ae89aad5 --- /dev/null +++ b/testdata/workspacetest/modD/go.mod @@ -0,0 +1,3 @@ +module workspace.test/modD + +go 1.25 diff --git a/testdata/workspacetest/modD/pkg/d.go b/testdata/workspacetest/modD/pkg/d.go new file mode 100644 index 00000000..1fdfdebf --- /dev/null +++ b/testdata/workspacetest/modD/pkg/d.go @@ -0,0 +1,3 @@ +package pkg + +func Standalone() string { return "standalone" } diff --git a/workspace_test.go b/workspace_test.go new file mode 100644 index 00000000..b6d869eb --- /dev/null +++ b/workspace_test.go @@ -0,0 +1,340 @@ +package gta + +import ( + "os" + "path/filepath" + "sort" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestWorkspaceRoots(t *testing.T) { + // Save and restore the working directory. + origDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chdir(origDir) }) + + wsDir, err := filepath.Abs(filepath.Join("testdata", "workspacetest")) + if err != nil { + t.Fatal(err) + } + + // Change to workspace directory so go env GOWORK finds the go.work file. + if err := os.Chdir(wsDir); err != nil { + t.Fatal(err) + } + + roots, err := workspaceroots() + if err != nil { + t.Fatalf("workspaceroots() error: %v", err) + } + + if roots == nil { + t.Fatal("workspaceroots() returned nil; expected workspace roots") + } + + // We expect 3 roots: modA, modB, modC (modD is not in go.work). + if len(roots) != 3 { + t.Fatalf("expected 3 roots, got %d: %v", len(roots), roots) + } + + sort.Strings(roots) + for i, want := range []string{"modA", "modB", "modC"} { + wantSuffix := filepath.Join("workspacetest", want) + if !containsSuffix(roots[i], wantSuffix) { + t.Errorf("roots[%d] = %q; want suffix %q", i, roots[i], wantSuffix) + } + } +} + +func TestWorkspaceRoots_NotInWorkspace(t *testing.T) { + // When not in a workspace directory, workspaceroots should return nil. + origDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chdir(origDir) }) + + // Use a temp dir that has no go.work. + tmpDir := t.TempDir() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + + // Force GOWORK=off to ensure we're not in workspace mode. + t.Setenv("GOWORK", "off") + + roots, err := workspaceroots() + if err != nil { + t.Fatalf("workspaceroots() error: %v", err) + } + + if roots != nil { + t.Fatalf("expected nil roots outside workspace, got %v", roots) + } +} + +func TestToplevel_Workspace(t *testing.T) { + origDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chdir(origDir) }) + + wsDir, err := filepath.Abs(filepath.Join("testdata", "workspacetest")) + if err != nil { + t.Fatal(err) + } + + if err := os.Chdir(wsDir); err != nil { + t.Fatal(err) + } + + roots, err := toplevel(false) + if err != nil { + t.Fatalf("toplevel(false) error: %v", err) + } + + if len(roots) != 3 { + t.Fatalf("expected 3 roots, got %d: %v", len(roots), roots) + } +} + +func TestToplevel_WorkspaceDisabled(t *testing.T) { + origDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chdir(origDir) }) + + wsDir, err := filepath.Abs(filepath.Join("testdata", "workspacetest", "modA")) + if err != nil { + t.Fatal(err) + } + + if err := os.Chdir(wsDir); err != nil { + t.Fatal(err) + } + + roots, err := toplevel(true) + if err != nil { + t.Fatalf("toplevel(true) error: %v", err) + } + + // With workspace disabled, should return only the single module root. + if len(roots) != 1 { + t.Fatalf("expected 1 root with workspace disabled, got %d: %v", len(roots), roots) + } + + if !containsSuffix(roots[0], "modA") { + t.Errorf("root = %q; want suffix 'modA'", roots[0]) + } +} + +func TestChangedPackages_WorkspaceCrossModule(t *testing.T) { + // Test that changing a package in modB causes dependents in modA and + // transitively modC to be detected. + origDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chdir(origDir) }) + + wsDir, err := filepath.Abs(filepath.Join("testdata", "workspacetest")) + if err != nil { + t.Fatal(err) + } + + if err := os.Chdir(wsDir); err != nil { + t.Fatal(err) + } + + // Build the differ: modB/pkg/b.go changed. + bPkgDir := filepath.Join(wsDir, "modB", "pkg") + difr := &testDiffer{ + diff: map[string]Directory{ + bPkgDir: {Exists: true, Files: []string{"b.go"}}, + }, + } + + gta, err := New(SetDiffer(difr)) + if err != nil { + t.Fatalf("New() error: %v", err) + } + + pkgs, err := gta.ChangedPackages() + if err != nil { + t.Fatalf("ChangedPackages() error: %v", err) + } + + // Expect: workspace.test/modB/pkg changed, and its dependents + // workspace.test/modA/pkg and workspace.test/modC/pkg should also be + // marked. + var gotPaths []string + for _, pkg := range pkgs.AllChanges { + gotPaths = append(gotPaths, pkg.ImportPath) + } + sort.Strings(gotPaths) + + wantPaths := []string{ + "workspace.test/modA/pkg", + "workspace.test/modB/pkg", + "workspace.test/modC/pkg", + } + + if diff := cmp.Diff(wantPaths, gotPaths); diff != "" { + t.Errorf("AllChanges import paths (-want +got):\n%s", diff) + } + + // Verify the direct change. + var changePaths []string + for _, pkg := range pkgs.Changes { + changePaths = append(changePaths, pkg.ImportPath) + } + if len(changePaths) != 1 || changePaths[0] != "workspace.test/modB/pkg" { + t.Errorf("Changes = %v; want [workspace.test/modB/pkg]", changePaths) + } +} + +func TestChangedPackages_WorkspaceIsolatedModule(t *testing.T) { + // Test that changing a package in a module with no dependents only + // reports that one package. + origDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chdir(origDir) }) + + wsDir, err := filepath.Abs(filepath.Join("testdata", "workspacetest")) + if err != nil { + t.Fatal(err) + } + + if err := os.Chdir(wsDir); err != nil { + t.Fatal(err) + } + + // modC/pkg has no dependents within the workspace. + cPkgDir := filepath.Join(wsDir, "modC", "pkg") + difr := &testDiffer{ + diff: map[string]Directory{ + cPkgDir: {Exists: true, Files: []string{"c.go"}}, + }, + } + + gta, err := New(SetDiffer(difr)) + if err != nil { + t.Fatalf("New() error: %v", err) + } + + pkgs, err := gta.ChangedPackages() + if err != nil { + t.Fatalf("ChangedPackages() error: %v", err) + } + + var gotPaths []string + for _, pkg := range pkgs.AllChanges { + gotPaths = append(gotPaths, pkg.ImportPath) + } + + wantPaths := []string{"workspace.test/modC/pkg"} + if diff := cmp.Diff(wantPaths, gotPaths); diff != "" { + t.Errorf("AllChanges import paths (-want +got):\n%s", diff) + } +} + +func TestChangedPackages_GoWorkFileChanged(t *testing.T) { + // When go.work itself is changed, all packages should be marked. + origDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chdir(origDir) }) + + wsDir, err := filepath.Abs(filepath.Join("testdata", "workspacetest")) + if err != nil { + t.Fatal(err) + } + + if err := os.Chdir(wsDir); err != nil { + t.Fatal(err) + } + + // go.work changed. + difr := &testDiffer{ + diff: map[string]Directory{ + wsDir: {Exists: true, Files: []string{"go.work"}}, + }, + } + + gta, err := New(SetDiffer(difr), SetPrefixes("workspace.test/")) + if err != nil { + t.Fatalf("New() error: %v", err) + } + + pkgs, err := gta.ChangedPackages() + if err != nil { + t.Fatalf("ChangedPackages() error: %v", err) + } + + // When go.work changes, all workspace packages should be in AllChanges. + if len(pkgs.AllChanges) < 3 { + var paths []string + for _, p := range pkgs.AllChanges { + paths = append(paths, p.ImportPath) + } + t.Errorf("expected at least 3 changed packages when go.work changes, got %d: %v", len(pkgs.AllChanges), paths) + } +} + +func TestSetDisableWorkspace(t *testing.T) { + origDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chdir(origDir) }) + + wsDir, err := filepath.Abs(filepath.Join("testdata", "workspacetest", "modA")) + if err != nil { + t.Fatal(err) + } + + if err := os.Chdir(wsDir); err != nil { + t.Fatal(err) + } + + // Fake differ showing a change in modA/pkg. + aPkgDir := filepath.Join(wsDir, "pkg") + difr := &testDiffer{ + diff: map[string]Directory{ + aPkgDir: {Exists: true, Files: []string{"a.go"}}, + }, + } + + // With workspace disabled, only modA packages should be loaded. + gta, err := New(SetDiffer(difr), SetDisableWorkspace(true)) + if err != nil { + t.Fatalf("New() error: %v", err) + } + + pkgs, err := gta.ChangedPackages() + if err != nil { + t.Fatalf("ChangedPackages() error: %v", err) + } + + // With workspace disabled, the packager only loads modA's packages. + // modC (which depends on modA) should NOT appear because it's in a + // different module. + for _, pkg := range pkgs.AllChanges { + if pkg.ImportPath == "workspace.test/modC/pkg" { + t.Error("modC/pkg should not appear when workspace is disabled") + } + } +} + +func containsSuffix(s, suffix string) bool { + return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix +}