Skip to content

Commit cfbb6e3

Browse files
authored
feat: add go tidy compat support to omnibump (#95)
* feat: Wire up support for tidy-compat Add and wire up the `--tidy-compat` flag. * chore(tests): Add Unit + Integration tests, plus supporting changes The biggest change here is to use an interface to call `exec.CommandContext` in `runner.go`, so that the call can be mocked by the new unit test. Also adds an integration test, w/ test go project, to verify Go is doing what we expect it to.
1 parent 88629f4 commit cfbb6e3

5 files changed

Lines changed: 178 additions & 27 deletions

File tree

cmd/omnibump/root.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ type rootFlags struct {
4040
rootDir string
4141
manifestFile string
4242
tidy bool
43+
tidyCompat string
4344
showDiff bool
4445
dryRun bool
4546
logLevel string
@@ -87,6 +88,7 @@ func New() *cobra.Command {
8788
f.StringVar(&flags.replaces, "replaces", "", "inline replace list (space-separated, format: oldpkg=newpkg@version)")
8889
f.StringVar(&flags.properties, "props", "", "inline properties list (space-separated)")
8990
f.StringVar(&flags.rootDir, "dir", ".", "project root directory")
91+
f.StringVar(&flags.tidyCompat, "tidy-compat", "", "set the go version for which the tidied go.mod and go.sum files should be compatible")
9092
f.BoolVar(&flags.tidy, "tidy", false, "run tidy command after update")
9193
f.BoolVar(&flags.showDiff, "show-diff", false, "show diff of changes")
9294
f.BoolVar(&flags.dryRun, "dry-run", false, "simulate update without making changes")
@@ -383,6 +385,9 @@ func buildUpdateConfig(cfg *config.Config) *languages.UpdateConfig {
383385
updateCfg.ShowDiff = flags.showDiff
384386
updateCfg.DryRun = flags.dryRun
385387
updateCfg.ManifestFile = flags.manifestFile
388+
if flags.tidyCompat != "" {
389+
updateCfg.Options["tidy-compat"] = flags.tidyCompat
390+
}
386391

387392
// The CLI's --manager flag always wins over a value in the deps file
388393
// (which ToUpdateConfig already stamped into Options).

pkg/languages/golang/runner.go

Lines changed: 26 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,23 @@ var ErrInvalidVersionQueryChar = errors.New("invalid character in version query"
3232
// ErrUnexpectedGoVersionOutput is returned when go version output has unexpected format.
3333
var ErrUnexpectedGoVersionOutput = errors.New("unexpected go version output")
3434

35+
// commander is the minimal surface of *exec.Cmd that this package uses.
36+
// It exists so tests can swap out commandContext below with a fake.
37+
type commander interface {
38+
CombinedOutput() ([]byte, error)
39+
}
40+
41+
// commandContext builds a commander for the given dir/name/args. Overridable in tests.
42+
// An empty dir leaves cmd.Dir unset (inheriting the parent process working directory),
43+
// matching the behavior of callers that don't pin a working directory.
44+
var commandContext = func(ctx context.Context, dir, name string, args ...string) commander {
45+
cmd := exec.CommandContext(ctx, name, args...) //nolint:gosec
46+
if dir != "" {
47+
cmd.Dir = dir
48+
}
49+
return cmd
50+
}
51+
3552
// validateModulePath validates a Go module path to prevent injection attacks.
3653
// Uses module.CheckPath() from golang.org/x/mod/module to ensure the path is valid.
3754
func validateModulePath(path string) error {
@@ -72,10 +89,7 @@ func GoModTidy(ctx context.Context, modroot, _ string, compat string) (string, e
7289
if compat != "" {
7390
args = append(args, "-compat", compat)
7491
}
75-
76-
cmd := exec.CommandContext(ctx, "go", args...)
77-
cmd.Dir = modroot
78-
if bytes, err := cmd.CombinedOutput(); err != nil {
92+
if bytes, err := commandContext(ctx, modroot, "go", args...).CombinedOutput(); err != nil {
7993
return strings.TrimSpace(string(bytes)), err
8094
}
8195
return "", nil
@@ -127,8 +141,7 @@ func UpdateGoWorkVersion(ctx context.Context, modroot string, forceWork bool, go
127141

128142
// Get Go version from environment if not provided
129143
if goVersion == "" {
130-
cmd := exec.CommandContext(ctx, "go", "version")
131-
output, err := cmd.CombinedOutput()
144+
output, err := commandContext(ctx, "", "go", "version").CombinedOutput()
132145
if err != nil {
133146
return fmt.Errorf("failed to get Go version: %w, output: %s", err, strings.TrimSpace(string(output)))
134147
}
@@ -174,9 +187,7 @@ func UpdateGoWorkVersion(ctx context.Context, modroot string, forceWork bool, go
174187

175188
dir := filepath.Dir(workPath)
176189
// Safe: goVersion is either auto-detected from runtime.Version() or user-provided version string (e.g., "1.21")
177-
cmd := exec.CommandContext(ctx, "go", "work", "edit", "-go", goVersion) //nolint:gosec // G204: goVersion is a version string, not user-controlled path
178-
cmd.Dir = dir
179-
if bytes, err := cmd.CombinedOutput(); err != nil {
190+
if bytes, err := commandContext(ctx, dir, "go", "work", "edit", "-go", goVersion).CombinedOutput(); err != nil {
180191
return fmt.Errorf("failed to update go.work version: %w, output: %s", err, strings.TrimSpace(string(bytes)))
181192
}
182193

@@ -187,15 +198,11 @@ func UpdateGoWorkVersion(ctx context.Context, modroot string, forceWork bool, go
187198
func GoVendor(ctx context.Context, dir string, forceWork bool) (string, error) {
188199
workPath := findGoWork(dir)
189200
if forceWork || workPath != "" {
190-
cmd := exec.CommandContext(ctx, "go", "work", "vendor")
191-
cmd.Dir = dir
192-
if bytes, err := cmd.CombinedOutput(); err != nil {
201+
if bytes, err := commandContext(ctx, dir, "go", "work", "vendor").CombinedOutput(); err != nil {
193202
return strings.TrimSpace(string(bytes)), err
194203
}
195204
} else {
196-
cmd := exec.CommandContext(ctx, "go", "mod", "vendor")
197-
cmd.Dir = dir
198-
if bytes, err := cmd.CombinedOutput(); err != nil {
205+
if bytes, err := commandContext(ctx, dir, "go", "mod", "vendor").CombinedOutput(); err != nil {
199206
return strings.TrimSpace(string(bytes)), err
200207
}
201208
}
@@ -213,9 +220,7 @@ func GoGetModule(ctx context.Context, name, version, modroot string) (string, er
213220
if err := validateVersionQuery(version); err != nil {
214221
return "", err
215222
}
216-
cmd := exec.CommandContext(ctx, "go", "get", fmt.Sprintf("%s@%s", name, version)) //nolint:gosec
217-
cmd.Dir = modroot
218-
if bytes, err := cmd.CombinedOutput(); err != nil {
223+
if bytes, err := commandContext(ctx, modroot, "go", "get", fmt.Sprintf("%s@%s", name, version)).CombinedOutput(); err != nil {
219224
return strings.TrimSpace(string(bytes)), err
220225
}
221226
return "", nil
@@ -235,15 +240,11 @@ func GoModEditReplaceModule(ctx context.Context, nameOld, nameNew, version, modr
235240
return "", fmt.Errorf("invalid version: %w", err)
236241
}
237242

238-
cmd := exec.CommandContext(ctx, "go", "mod", "edit", "-dropreplace", nameOld) //nolint:gosec
239-
cmd.Dir = modroot
240-
if bytes, err := cmd.CombinedOutput(); err != nil {
243+
if bytes, err := commandContext(ctx, modroot, "go", "mod", "edit", "-dropreplace", nameOld).CombinedOutput(); err != nil {
241244
return strings.TrimSpace(string(bytes)), fmt.Errorf("error running go command to drop replace modules: %w", err)
242245
}
243246

244-
cmd = exec.CommandContext(ctx, "go", "mod", "edit", "-replace", fmt.Sprintf("%s=%s@%s", nameOld, nameNew, version)) //nolint:gosec
245-
cmd.Dir = modroot
246-
if bytes, err := cmd.CombinedOutput(); err != nil {
247+
if bytes, err := commandContext(ctx, modroot, "go", "mod", "edit", "-replace", fmt.Sprintf("%s=%s@%s", nameOld, nameNew, version)).CombinedOutput(); err != nil {
247248
return strings.TrimSpace(string(bytes)), fmt.Errorf("error running go command to replace modules: %w", err)
248249
}
249250
return "", nil
@@ -256,9 +257,7 @@ func GoModEditDropRequireModule(ctx context.Context, name, modroot string) (stri
256257
return "", err
257258
}
258259
// Safe: module path validated above
259-
cmd := exec.CommandContext(ctx, "go", "mod", "edit", "-droprequire", name) //nolint:gosec
260-
cmd.Dir = modroot
261-
if bytes, err := cmd.CombinedOutput(); err != nil {
260+
if bytes, err := commandContext(ctx, modroot, "go", "mod", "edit", "-droprequire", name).CombinedOutput(); err != nil {
262261
return strings.TrimSpace(string(bytes)), err
263262
}
264263

pkg/languages/golang/runner_test.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,50 @@ import (
1414
"runtime"
1515
"strings"
1616
"testing"
17+
18+
"github.com/stretchr/testify/assert"
1719
)
1820

21+
// fakeCmd implements commander; it records the invocation it was built with
22+
// and returns the stubbed output/error when CombinedOutput is called.
23+
type fakeCmd struct {
24+
dir, name string
25+
args []string
26+
out []byte
27+
err error
28+
}
29+
30+
func (f *fakeCmd) CombinedOutput() ([]byte, error) { return f.out, f.err }
31+
32+
// fakeRunner captures every command built via commandContext and produces a
33+
// fakeCmd. Stub responses per-command by populating responses keyed on the
34+
// joined "name arg1 arg2 ..." string.
35+
type fakeRunner struct {
36+
calls []fakeCmd
37+
responses map[string]struct {
38+
out []byte
39+
err error
40+
}
41+
}
42+
43+
func (r *fakeRunner) factory(_ context.Context, dir, name string, args ...string) commander {
44+
key := strings.Join(append([]string{name}, args...), " ")
45+
resp := r.responses[key]
46+
cmd := fakeCmd{dir: dir, name: name, args: args, out: resp.out, err: resp.err}
47+
r.calls = append(r.calls, cmd)
48+
return &cmd
49+
}
50+
51+
// withFakeRunner swaps commandContext for the duration of a test.
52+
func withFakeRunner(t *testing.T) *fakeRunner {
53+
t.Helper()
54+
r := &fakeRunner{}
55+
orig := commandContext
56+
commandContext = r.factory
57+
t.Cleanup(func() { commandContext = orig })
58+
return r
59+
}
60+
1961
func TestGoWork(t *testing.T) {
2062
// Skip if go command is not available
2163
if _, err := exec.LookPath("go"); err != nil {
@@ -323,6 +365,86 @@ github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
323365
})
324366
}
325367

368+
func TestGoTidy_Integration(t *testing.T) {
369+
// Skip if go command is not available
370+
if _, err := exec.LookPath("go"); err != nil {
371+
t.Skip("go command not found, skipping test")
372+
}
373+
374+
ctx := context.Background()
375+
376+
t.Run("GoModTidy", func(t *testing.T) {
377+
testCases := []struct {
378+
name string
379+
compat string
380+
error bool
381+
}{
382+
{
383+
name: "mod tidy w/o compat",
384+
compat: "",
385+
error: true,
386+
},
387+
{
388+
name: "mod tidy w/ compat",
389+
compat: "1.17",
390+
error: false,
391+
},
392+
}
393+
for _, tc := range testCases {
394+
t.Run(tc.name, func(t *testing.T) {
395+
tmpDir := t.TempDir()
396+
copyFile(t, "testdata/tidy-compat/go.mod", tmpDir)
397+
copyFile(t, "testdata/tidy-compat/main.go", tmpDir)
398+
399+
_, err := GoModTidy(ctx, tmpDir, "", tc.compat)
400+
// require.NoError(t, err)
401+
assert.Equal(t, err != nil, tc.error)
402+
403+
// Check if go.sum is created (only on success)
404+
cmd := exec.CommandContext(ctx, "ls", "-la", "go.sum")
405+
cmd.Dir = tmpDir
406+
output, err := cmd.Output()
407+
408+
if tc.error {
409+
assert.Error(t, err)
410+
} else {
411+
assert.NoError(t, err)
412+
assert.Contains(t, string(output), "go.sum")
413+
}
414+
})
415+
}
416+
})
417+
}
418+
419+
func TestGoTidy_Unit(t *testing.T) {
420+
tests := []struct {
421+
name string
422+
tidyCompat string
423+
expected string
424+
}{
425+
{name: "valid tidy w/o compat", tidyCompat: "", expected: "go mod tidy"},
426+
{name: "valid tidy w/ compat", tidyCompat: "1.20", expected: "go mod tidy -compat 1.20"},
427+
}
428+
429+
for _, tt := range tests {
430+
t.Run(tt.name, func(t *testing.T) {
431+
r := withFakeRunner(t)
432+
if _, err := GoModTidy(context.Background(), "/some/modroot", "", tt.tidyCompat); err != nil {
433+
t.Fatalf("GoModTidy returned error: %v", err)
434+
}
435+
if len(r.calls) != 1 {
436+
t.Fatalf("expected 1 command, got %d", len(r.calls))
437+
}
438+
got := r.calls[0]
439+
command := got.name + " " + strings.Join(got.args, " ")
440+
441+
if tt.expected != command {
442+
t.Errorf("command: got %q, want %q", tt.expected, command)
443+
}
444+
})
445+
}
446+
}
447+
326448
// TestValidateModulePath tests module path validation against injection attacks.
327449
func TestValidateModulePath(t *testing.T) {
328450
tests := []struct {
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
module test
2+
3+
go 1.17
4+
5+
require (
6+
github.com/falcosecurity/client-go v0.5.0
7+
github.com/prometheus/client_golang v1.12.2
8+
github.com/spf13/pflag v1.0.5
9+
google.golang.org/grpc v1.56.3
10+
)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/*
2+
Copyright 2026 Chainguard, Inc.
3+
SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package main
7+
8+
import (
9+
_ "github.com/falcosecurity/client-go/pkg/client"
10+
_ "github.com/prometheus/client_golang/prometheus"
11+
_ "github.com/spf13/pflag"
12+
_ "google.golang.org/grpc"
13+
)
14+
15+
func main() {}

0 commit comments

Comments
 (0)