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: Add work command #311

Merged
merged 22 commits into from
May 18, 2023
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .chloggen/crosslink-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 work command generating go.work file."

# 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

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

checkdoc/checkdoc
chloggen/chloggen
Expand Down
5 changes: 5 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -208,3 +208,8 @@ chlog-update: | $(CHLOGGEN)
crosslink: | $(CROSSLINK)
@echo "Updating intra-repository dependencies in all go modules" \
&& $(CROSSLINK) --root=$(shell pwd) --prune

.PHONY: gowork
gowork: | $(CROSSLINK)
$(CROSSLINK) work --root=$(shell pwd)

8 changes: 8 additions & 0 deletions crosslink/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,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.
Crosslink can generate a `go.work` file to facilitate local development of a
repiository containing multiple Go modules.
pellared marked this conversation as resolved.
Show resolved Hide resolved
Crosslink also contains functionality to remove any extra replace statements
that are no longer required within reason (see below).

Expand Down Expand Up @@ -128,3 +130,9 @@ Can be disabled when overwriting.

**Quick Tip: Make sure your `go.mod` files are tracked and committed in a VCS
before running crosslink.**

### work
pellared marked this conversation as resolved.
Show resolved Hide resolved

Creates or updates existing `go.work` file by adding use statements
for all intra-repository Go modules. It also removes use statements
for out-dated intra-repository Go modules.
19 changes: 16 additions & 3 deletions crosslink/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type commandConfig struct {
excludeFlags []string
rootCommand cobra.Command
pruneCommand cobra.Command
workCommand cobra.Command
}

func newCommandConfig() *commandConfig {
Expand Down Expand Up @@ -105,6 +106,16 @@ func newCommandConfig() *commandConfig {
},
}
c.rootCommand.AddCommand(&c.pruneCommand)

c.workCommand = cobra.Command{
Use: "work",
Short: "Generate or update the go.work file with use statements for intra-repository dependencies",
RunE: func(cmd *cobra.Command, args []string) error {
return cl.Work(c.runConfig)
},
}
c.rootCommand.AddCommand(&c.workCommand)

return c
}

Expand All @@ -123,14 +134,16 @@ func Execute() {
}

func init() {

comCfg.rootCommand.PersistentFlags().StringVar(&comCfg.runConfig.RootPath, "root", "", `path to root directory of multi-module repository. If --root flag is not provided crosslink will attempt to find a
git repository in the current or a parent directory.`)
comCfg.rootCommand.PersistentFlags().StringSliceVar(&comCfg.excludeFlags, "exclude", []string{}, "list of comma separated go modules that crosslink will ignore in operations."+
"multiple calls of --exclude can be made")
comCfg.rootCommand.PersistentFlags().BoolVarP(&comCfg.runConfig.Verbose, "verbose", "v", false, "verbose output")
comCfg.rootCommand.Flags().StringSliceVar(&comCfg.excludeFlags, "exclude", []string{}, "list of comma separated go modules that crosslink will ignore in operations."+
MrAlias marked this conversation as resolved.
Show resolved Hide resolved
"multiple calls of --exclude can be made")
comCfg.rootCommand.Flags().BoolVar(&comCfg.runConfig.Overwrite, "overwrite", false, "overwrite flag allows crosslink to make destructive (replacing or updating) actions to existing go.mod files")
comCfg.rootCommand.Flags().BoolVarP(&comCfg.runConfig.Prune, "prune", "p", false, "enables pruning operations on all go.mod files inside root repository")
comCfg.pruneCommand.Flags().StringSliceVar(&comCfg.excludeFlags, "exclude", []string{}, "list of comma separated go modules that crosslink will ignore in operations."+
pellared marked this conversation as resolved.
Show resolved Hide resolved
"multiple calls of --exclude can be made")
comCfg.workCommand.Flags().StringVar(&comCfg.runConfig.GoVersion, "go", "1.19", "Go version applied when new go.work is created")
pellared marked this conversation as resolved.
Show resolved Hide resolved
}

// transform array slice into map
Expand Down
20 changes: 20 additions & 0 deletions crosslink/internal/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ package crosslink

import (
"fmt"
"io/fs"
"os"
"path/filepath"

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

Expand Down Expand Up @@ -53,3 +55,21 @@ func writeModule(module *moduleInfo) error {

return nil
}

func forGoModules(logger *zap.Logger, rootPath string, fn func(path string) error) error {
mx-psi marked this conversation as resolved.
Show resolved Hide resolved
return fs.WalkDir(os.DirFS(rootPath), ".", func(path string, dir fs.DirEntry, err error) error {
if err != nil {
logger.Warn("File could not be read during fs.WalkDir",
zap.Error(err),
zap.String("path", path))
return nil
}

// find Go module
if dir.Name() != "go.mod" {
return nil
}

return fn(path)
})
}
1 change: 1 addition & 0 deletions crosslink/internal/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ type RunConfig struct {
ExcludedPaths map[string]struct{}
Overwrite bool
Prune bool
GoVersion string
Logger *zap.Logger
}

Expand Down
36 changes: 12 additions & 24 deletions crosslink/internal/graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ package crosslink

import (
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
Expand All @@ -31,34 +30,23 @@ import (
func buildDepedencyGraph(rc RunConfig, rootModulePath string) (map[string]*moduleInfo, error) {
moduleMap := make(map[string]*moduleInfo)

goModFunc := func(filePath string, info fs.FileInfo, err error) error {
err := forGoModules(rc.Logger, rc.RootPath, func(path string) error {
fullPath := filepath.Join(rc.RootPath, path)
modFile, err := os.ReadFile(filepath.Clean(fullPath))
if err != nil {
rc.Logger.Error("File could not be read during filePath.Walk",
zap.Error(err),
zap.String("file_path", filePath))

return nil
return fmt.Errorf("failed to read file: %w", err)
}

if filepath.Base(filePath) == "go.mod" {
modFile, err := os.ReadFile(filepath.Clean(filePath))
if err != nil {
return fmt.Errorf("failed to read file: %w", err)
}

modContents, err := modfile.Parse(filePath, modFile, nil)
if err != nil {
rc.Logger.Error("Modfile could not be parsed",
zap.Error(err),
zap.String("file_path", filePath))
}

moduleMap[modfile.ModulePath(modFile)] = newModuleInfo(*modContents)
modContents, err := modfile.Parse(fullPath, modFile, nil)
if err != nil {
rc.Logger.Error("Modfile could not be parsed",
zap.Error(err),
zap.String("file_path", path))
}
return nil
}

err := filepath.Walk(rc.RootPath, goModFunc)
moduleMap[modfile.ModulePath(modFile)] = newModuleInfo(*modContents)
return nil
})
if err != nil {
return nil, fmt.Errorf("failed during file walk: %w", err)
}
Expand Down
13 changes: 13 additions & 0 deletions crosslink/internal/mock_test_data/testWork/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/testWork/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
3 changes: 3 additions & 0 deletions crosslink/internal/mock_test_data/testWork/testA/gomod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module go.opentelemetry.io/build-tools/crosslink/testroot/testA

go 1.19
3 changes: 3 additions & 0 deletions crosslink/internal/mock_test_data/testWork/testB/gomod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module go.opentelemetry.io/build-tools/crosslink/testroot/testB

go 1.19
144 changes: 144 additions & 0 deletions crosslink/internal/work.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// Copyright The OpenTelemetry Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package crosslink

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

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

// Work is the main entry point for the work subcommand.
func Work(rc RunConfig) error {
rc.Logger.Debug("Crosslink run config", zap.Any("run_config", rc))

uses, err := intraRepoUses(rc)
if err != nil {
return fmt.Errorf("failed to find Go modules: %w", err)
}

goWork, err := openGoWork(rc)
if errors.Is(err, os.ErrNotExist) {
goWork = &modfile.WorkFile{
Syntax: &modfile.FileSyntax{},
}
if addErr := goWork.AddGoStmt(rc.GoVersion); addErr != nil {
return fmt.Errorf("failed to create go.work: %w", addErr)
}
} else if err != nil {
return err
}

insertUses(goWork, uses, rc)
pruneUses(goWork, uses, rc)

return writeGoWork(goWork, rc)
}

func intraRepoUses(rc RunConfig) ([]string, error) {
var uses []string
err := forGoModules(rc.Logger, rc.RootPath, func(path string) error {
// normalize use statement (path)
use := filepath.Dir(path)
if use == "." {
use = "./"
} else {
use = "./" + use
}

uses = append(uses, use)
return nil
})
return uses, err
}

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)
}

// insertUses adds any missing intra-repository use statements.
func insertUses(goWork *modfile.WorkFile, uses []string, rc RunConfig) {
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))
}
}
}

// pruneUses removes any extraneous intra-repository use statements.
func pruneUses(goWork *modfile.WorkFile, uses []string, rc RunConfig) {
requiredUses := make(map[string]bool, len(uses))
for _, use := range uses {
requiredUses[use] = true
}

usesToKeep := make(map[string]bool, len(goWork.Use))
for _, use := range goWork.Use {
usesToKeep[use.Path] = true
}

for use := range usesToKeep {
// check to see if its intra dependency
if !strings.HasPrefix(use, "./") {
continue
}

// check if the intra dependency is still used
if requiredUses[use] {
continue
}

usesToKeep[use] = false
}

// remove unnecessary uses
for use, needed := range usesToKeep {
if needed {
continue
}

err := goWork.DropUse(use)
if err != nil {
rc.Logger.Error("Failed to drop use statement", zap.Error(err),
zap.String("path", use))
}
}
}
Loading