Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

crosslink: Support go.work #308

Closed
wants to merge 17 commits into from
16 changes: 16 additions & 0 deletions .chloggen/support-go.work.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix'
change_type: enhancement

# The name of the component, or a single word describing the area of concern, (e.g. crosslink)
component: crosslink

# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
note: "Add support for go.work files"

# One or more tracking issues related to the change
issues: [309]

# (Optional) One or more lines of additional information to render under the primary note.
# These lines will be padded with 2 spaces and then inserted directly into the document.
# Use pipe (|) for multiline entries.
subtext:
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ Thumbs.db
*.iml
*.so
coverage.*
go.work
go.work.sum
Copy link
Member Author

@pellared pellared May 10, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It even is not clear how the go.work.sum is updated and how it is used so I see no reason to add it. Reference golang/go#51941


crosslink/internal/test_data/
!crosslink/internal/test_data/.placeholder

checkdoc/checkdoc
chloggen/chloggen
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -206,5 +206,5 @@ chlog-update: | $(CHLOGGEN)

.PHONY: crosslink
crosslink: | $(CROSSLINK)
@echo "Updating intra-repository dependencies in all go modules" \
@echo "Updating intra-repository dependencies in all go modules and go.work" \
&& $(CROSSLINK) --root=$(shell pwd) --prune
3 changes: 2 additions & 1 deletion crosslink/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

Crosslink is a tool to assist in managing go repositories that contain multiple
intra-repository `go.mod` files. Crosslink automatically scans and inserts
replace statements for direct and transitive intra-repository dependencies.
replace statements for direct and transitive intra-repository dependencies
to `go.mod` files as well as root `go.work` file (if exists).
Crosslink also contains functionality to remove any extra replace statements
that are no longer required within reason (see below).

Expand Down
51 changes: 51 additions & 0 deletions crosslink/internal/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"

"go.uber.org/zap"
"golang.org/x/mod/modfile"
)

Expand All @@ -38,6 +41,26 @@ func identifyRootModule(rootPath string) (string, error) {
return modfile.ModulePath(rootModFile), nil
}

func buildUses(rootModulePath string, graph map[string]*moduleInfo, rc RunConfig) ([]string, error) {
var uses []string
for module := range graph {
// skip excluded
if _, exists := rc.ExcludedPaths[module]; exists {
rc.Logger.Debug("Excluded Module, ignoring use",
zap.String("module", module))
continue
}

localPath, err := relativeModulePath(rootModulePath, module)
if err != nil {
return nil, err
}
uses = append(uses, localPath)
}
sort.Strings(uses)
return uses, nil
}

func writeModule(module *moduleInfo) error {
modContents := module.moduleContents
// now overwrite the existing gomod file
Expand All @@ -53,3 +76,31 @@ func writeModule(module *moduleInfo) error {

return nil
}

func openGoWork(rc RunConfig) (*modfile.WorkFile, error) {
goWorkPath := filepath.Join(rc.RootPath, "go.work")
content, err := os.ReadFile(filepath.Clean(goWorkPath))
if err != nil {
return nil, err
}
return modfile.ParseWork(goWorkPath, content, nil)
}

func writeGoWork(goWork *modfile.WorkFile, rc RunConfig) error {
goWorkPath := filepath.Join(rc.RootPath, "go.work")
content := modfile.Format(goWork.Syntax)
return os.WriteFile(goWorkPath, content, 0600)
}

func relativeModulePath(rootModule, module string) (string, error) {
localPath, err := filepath.Rel(rootModule, module)
if err != nil {
return "", fmt.Errorf("failed to retrieve relative path: %w", err)
}
if localPath == "." || localPath == ".." {
localPath += "/"
} else if !strings.HasPrefix(localPath, "..") {
localPath = "./" + localPath
}
return localPath, nil
}
55 changes: 45 additions & 10 deletions crosslink/internal/crosslink.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
package crosslink

import (
"errors"
"fmt"
"path/filepath"
"strings"
"os"

"go.uber.org/zap"
"golang.org/x/mod/modfile"
Expand All @@ -38,6 +38,7 @@ func Crosslink(rc RunConfig) error {
return fmt.Errorf("failed to build dependency graph: %w", err)
}

// update go.mod files
for moduleName, moduleInfo := range graph {
err = insertReplace(moduleInfo, rc)
logger := rc.Logger.With(zap.String("module", moduleName))
Expand All @@ -57,7 +58,13 @@ func Crosslink(rc RunConfig) error {
zap.Error(err))
}
}
return nil

// update go.work file
uses, err := buildUses(rootModulePath, graph, rc)
if err != nil {
return err
}
return updateGoWork(uses, rc)
}

func insertReplace(module *moduleInfo, rc RunConfig) error {
Expand All @@ -72,14 +79,9 @@ func insertReplace(module *moduleInfo, rc RunConfig) error {
continue
}

localPath, err := filepath.Rel(modContents.Module.Mod.Path, reqModule)
localPath, err := relativeModulePath(modContents.Module.Mod.Path, reqModule)
if err != nil {
return fmt.Errorf("failed to retrieve relative path: %w", err)
}
if localPath == "." || localPath == ".." {
localPath += "/"
} else if !strings.HasPrefix(localPath, "..") {
localPath = "./" + localPath
return err
}

if oldReplace, exists := containsReplace(modContents.Replace, reqModule); exists {
Expand Down Expand Up @@ -129,3 +131,36 @@ func containsReplace(replaceStatments []*modfile.Replace, modName string) (*modf
}
return nil, false
}

func updateGoWork(uses []string, rc RunConfig) error {
goWork, err := openGoWork(rc)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}

// add missing uses
existingGoWorkUses := make(map[string]bool, len(goWork.Use))
for _, use := range goWork.Use {
existingGoWorkUses[use.Path] = true
}

for _, useToAdd := range uses {
if existingGoWorkUses[useToAdd] {
continue
}
err := goWork.AddUse(useToAdd, "")
if err != nil {
rc.Logger.Error("Failed to add use statement", zap.Error(err),
zap.String("path", useToAdd))
}
}

if rc.Prune {
pruneUse(goWork, uses, rc)
}

return writeGoWork(goWork, rc)
}
158 changes: 158 additions & 0 deletions crosslink/internal/crosslink_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"github.com/google/go-cmp/cmp/cmpopts"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"golang.org/x/mod/modfile"
)
Expand Down Expand Up @@ -441,3 +442,160 @@ func TestBadRootPath(t *testing.T) {
}

}

func TestGoWork(t *testing.T) {
lg, _ := zap.NewDevelopment()

tests := []struct {
testName string
config RunConfig
expected string
}{
{
testName: "default",
config: RunConfig{Logger: lg},
expected: `go 1.19

// new statement added by crosslink
use ./

// existing valid use statements under root should remain
use ./testA

// new statement added by crossling
use ./testB

// invalid use statements under root should be removed ONLY if prune is used
use ./testC

// use statements outside the root should remain
use ../other-module

// replace statements should remain
replace foo.opentelemetery.io/bar => ../bar`,
},
{
testName: "prune",
config: RunConfig{Logger: lg, Prune: true},
expected: `go 1.19

// new statement added by crosslink
use ./

// existing valid use statements under root should remain
use ./testA

// new statement added by crosslink
use ./testB

// invalid use statements under root is REMOVED when prune is used
// use ./testC

// use statements outside the root should remain
use ../other-module

// replace statements should remain
replace foo.opentelemetery.io/bar => ../bar`,
},
{
testName: "excluded",
config: RunConfig{Logger: lg, ExcludedPaths: map[string]struct{}{
"go.opentelemetry.io/build-tools/crosslink/testroot/testB": {},
}},
expected: `go 1.19

// new statement added by crosslink
use ./

// existing valid use statements under root should remain
use ./testA

// do not add EXCLUDED modules
// use ./testB

// invalid use statements under root should be removed ONLY if prune is used
use ./testC

// use statements outside the root should remain
use ../other-module

// replace statements should remain
replace foo.opentelemetery.io/bar => ../bar`,
},
}

for _, test := range tests {
t.Run(test.testName, func(t *testing.T) {
mockDir := "testGoWork"
tmpRootDir, err := createTempTestDir(mockDir)
if err != nil {
t.Fatal("creating temp dir:", err)
}

err = renameGoMod(tmpRootDir)
if err != nil {
t.Errorf("error renaming gomod files: %v", err)
}
t.Cleanup(func() { os.RemoveAll(tmpRootDir) })

test.config.RootPath = tmpRootDir

err = Crosslink(test.config)
require.NoError(t, err)
goWorkContent, err := os.ReadFile(filepath.Clean(filepath.Join(tmpRootDir, "go.work")))
require.NoError(t, err)

actual, err := modfile.ParseWork("go.work", goWorkContent, nil)
require.NoError(t, err)
actual.Cleanup()

expected, err := modfile.ParseWork("go.work", []byte(test.expected), nil)
require.NoError(t, err)
expected.Cleanup()

// replace structs need to be assorted to avoid flaky fails in test
replaceSortFunc := func(x, y *modfile.Replace) bool {
return x.Old.Path < y.Old.Path
}

// use structs need to be assorted to avoid flaky fails in test
useSortFunc := func(x, y *modfile.Use) bool {
return x.Path < y.Path
}

if diff := cmp.Diff(expected, actual,
cmpopts.IgnoreFields(modfile.Use{}, "Syntax", "ModulePath"),
cmpopts.IgnoreFields(modfile.Replace{}, "Syntax"),
cmpopts.IgnoreFields(modfile.WorkFile{}, "Syntax"),
cmpopts.SortSlices(replaceSortFunc),
cmpopts.SortSlices(useSortFunc),
); diff != "" {
t.Errorf("go.work mismatch (-want +got):\n%s", diff)
}
})
}
}

func TestNoGoWork(t *testing.T) {
// go.work file is not created by crosslink. It is only modified if it exists.
mockDir := "testSimple"
config := DefaultRunConfig()

tmpRootDir, err := createTempTestDir(mockDir)
if err != nil {
t.Fatal("creating temp dir:", err)
}

err = renameGoMod(tmpRootDir)
if err != nil {
t.Errorf("error renaming gomod files: %v", err)
}
t.Cleanup(func() { os.RemoveAll(tmpRootDir) })

config.RootPath = tmpRootDir

err = Crosslink(config)

require.NoError(t, err)
assert.NoFileExists(t, filepath.Join(tmpRootDir, "go.work"))
}
1 change: 0 additions & 1 deletion crosslink/internal/helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,4 @@ func createTempTestDir(testName string) (string, error) {
}

return tmpRootDir, nil

}
13 changes: 13 additions & 0 deletions crosslink/internal/mock_test_data/testGoWork/go.work
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
go 1.19

// existing valid use statements under root should remain
use ./testA

// invalid use statements under root should be removed ONLY if prune is used
use ./testC

// use statements outside the root should remain
use ../other-module

// replace statements should remain
replace foo.opentelemetery.io/bar => ../bar
3 changes: 3 additions & 0 deletions crosslink/internal/mock_test_data/testGoWork/gomod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module go.opentelemetry.io/build-tools/crosslink/testroot

go 1.19
Loading