Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
27 changes: 27 additions & 0 deletions internal/gitclone/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,23 @@ import (
"github.com/alecthomas/errors"
)

var (
sharedManager *Manager
sharedManagerMu sync.RWMutex
)

func SetShared(m *Manager) {
sharedManagerMu.Lock()
defer sharedManagerMu.Unlock()
sharedManager = m
}

func GetShared() *Manager {
sharedManagerMu.RLock()
defer sharedManagerMu.RUnlock()
return sharedManager
}
Comment on lines +18 to +33
Copy link
Collaborator

Choose a reason for hiding this comment

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

I can't tell why this exists. We should be constructing a single Manager in main, then passing it everywhere, so AFAICT we shouldn't need this?

Copy link
Collaborator Author

@js-murph js-murph Feb 5, 2026

Choose a reason for hiding this comment

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

Because of where things load and where things need to be constructed, this gets more complicated than you might think. I'll address this as a separate PR because I think it might be easier to illustrate the problem and then we can work out what to do.


type State int

const (
Expand Down Expand Up @@ -429,3 +446,13 @@ func (r *Repository) GetUpstreamRefs(ctx context.Context) (map[string]string, er

return ParseGitRefs(output), nil
}

func (r *Repository) HasCommit(ctx context.Context, ref string) bool {
r.mu.RLock()
defer r.mu.RUnlock()

// #nosec G204 - r.path and ref are controlled by us
cmd := exec.CommandContext(ctx, "git", "-C", r.path, "cat-file", "-e", ref)
err := cmd.Run()
return err == nil
}
41 changes: 41 additions & 0 deletions internal/gitclone/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package gitclone //nolint:testpackage // white-box testing required for unexport
import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
Expand Down Expand Up @@ -214,3 +215,43 @@ func TestState_String(t *testing.T) {
assert.Equal(t, "cloning", StateCloning.String())
assert.Equal(t, "ready", StateReady.String())
}

func TestRepository_HasCommit(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
repoPath := filepath.Join(tmpDir, "test-repo")

assert.NoError(t, os.MkdirAll(repoPath, 0o755))

cmd := exec.Command("git", "-C", repoPath, "init")
assert.NoError(t, cmd.Run())

cmd = exec.Command("git", "-C", repoPath, "config", "user.email", "test@example.com")
assert.NoError(t, cmd.Run())
cmd = exec.Command("git", "-C", repoPath, "config", "user.name", "Test User")
assert.NoError(t, cmd.Run())

testFile := filepath.Join(repoPath, "test.txt")
assert.NoError(t, os.WriteFile(testFile, []byte("test content"), 0o644))
cmd = exec.Command("git", "-C", repoPath, "add", "test.txt")
assert.NoError(t, cmd.Run())
cmd = exec.Command("git", "-C", repoPath, "commit", "-m", "Initial commit")
assert.NoError(t, cmd.Run())

cmd = exec.Command("git", "-C", repoPath, "tag", "v1.0.0")
assert.NoError(t, cmd.Run())

repo := &Repository{
state: StateReady,
path: repoPath,
upstreamURL: "https://example.com/test-repo",
fetchSem: make(chan struct{}, 1),
}
repo.fetchSem <- struct{}{}

assert.True(t, repo.HasCommit(ctx, "HEAD"))
assert.True(t, repo.HasCommit(ctx, "v1.0.0"))

assert.False(t, repo.HasCommit(ctx, "nonexistent"))
assert.False(t, repo.HasCommit(ctx, "v9.9.9"))
}
2 changes: 2 additions & 0 deletions internal/strategy/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ func New(ctx context.Context, config Config, scheduler jobscheduler.Scheduler, c
return nil, errors.Wrap(err, "create clone manager")
}

gitclone.SetShared(cloneManager)

s := &Strategy{
config: config,
cache: cache,
Expand Down
55 changes: 55 additions & 0 deletions internal/strategy/gomod/fetcher.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package gomod

import (
"context"
"io"
"time"

"github.com/alecthomas/errors"
"github.com/goproxy/goproxy"
)

type compositeFetcher struct {
publicFetcher goproxy.Fetcher
privateFetcher goproxy.Fetcher
matcher *ModulePathMatcher
}

func newCompositeFetcher(
publicFetcher goproxy.Fetcher,
privateFetcher goproxy.Fetcher,
patterns []string,
) *compositeFetcher {
return &compositeFetcher{
publicFetcher: publicFetcher,
privateFetcher: privateFetcher,
matcher: NewModulePathMatcher(patterns),
}
}

func (c *compositeFetcher) Query(ctx context.Context, path, query string) (version string, t time.Time, err error) {
if c.matcher.IsPrivate(path) {
v, tm, err := c.privateFetcher.Query(ctx, path, query)
return v, tm, errors.Wrap(err, "private fetcher query")
}
v, tm, err := c.publicFetcher.Query(ctx, path, query)
return v, tm, errors.Wrap(err, "public fetcher query")
}

func (c *compositeFetcher) List(ctx context.Context, path string) (versions []string, err error) {
if c.matcher.IsPrivate(path) {
v, err := c.privateFetcher.List(ctx, path)
return v, errors.Wrap(err, "private fetcher list")
}
v, err := c.publicFetcher.List(ctx, path)
return v, errors.Wrap(err, "public fetcher list")
}

func (c *compositeFetcher) Download(ctx context.Context, path, version string) (info, mod, zip io.ReadSeekCloser, err error) {
if c.matcher.IsPrivate(path) {
i, m, z, err := c.privateFetcher.Download(ctx, path, version)
return i, m, z, errors.Wrap(err, "private fetcher download")
}
i, m, z, err := c.publicFetcher.Download(ctx, path, version)
return i, m, z, errors.Wrap(err, "public fetcher download")
}
48 changes: 35 additions & 13 deletions internal/strategy/gomod/gomod.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import (
"net/http"
"net/url"

"github.com/alecthomas/errors"
"github.com/goproxy/goproxy"

"github.com/block/cachew/internal/cache"
"github.com/block/cachew/internal/gitclone"
"github.com/block/cachew/internal/jobscheduler"
"github.com/block/cachew/internal/logging"
"github.com/block/cachew/internal/strategy"
Expand All @@ -20,15 +22,17 @@ func init() {
}

type Config struct {
Proxy string `hcl:"proxy,optional" help:"Upstream Go module proxy URL (defaults to proxy.golang.org)" default:"https://proxy.golang.org"`
Proxy string `hcl:"proxy,optional" help:"Upstream Go module proxy URL (defaults to proxy.golang.org)" default:"https://proxy.golang.org"`
PrivatePaths []string `hcl:"private-paths,optional" help:"Module path patterns for private repositories"`
}

type Strategy struct {
config Config
cache cache.Cache
logger *slog.Logger
proxy *url.URL
goproxy *goproxy.Goproxy
config Config
cache cache.Cache
logger *slog.Logger
proxy *url.URL
goproxy *goproxy.Goproxy
cloneManager *gitclone.Manager
}

var _ strategy.Strategy = (*Strategy)(nil)
Expand All @@ -46,14 +50,32 @@ func New(ctx context.Context, config Config, _ jobscheduler.Scheduler, cache cac
proxy: parsedURL,
}

s.goproxy = &goproxy.Goproxy{
Logger: s.logger,
Fetcher: &goproxy.GoFetcher{
Env: []string{
"GOPROXY=" + config.Proxy,
"GOSUMDB=off", // Disable checksum database validation in fetcher, to prevent unneccessary double validation
},
publicFetcher := &goproxy.GoFetcher{
Env: []string{
"GOPROXY=" + config.Proxy,
"GOSUMDB=off", // Disable checksum database validation in fetcher, to prevent unneccessary double validation
},
}

var fetcher goproxy.Fetcher = publicFetcher

if len(config.PrivatePaths) > 0 {
cloneManager := gitclone.GetShared()
if cloneManager == nil {
return nil, errors.New("private-paths configured but git strategy not initialized - git strategy with mirror-root is required for private module support")
}

s.cloneManager = cloneManager
privateFetcher := newPrivateFetcher(s, cloneManager)
fetcher = newCompositeFetcher(publicFetcher, privateFetcher, config.PrivatePaths)

s.logger.InfoContext(ctx, "Configured private module support",
slog.Any("private_paths", config.PrivatePaths))
}

s.goproxy = &goproxy.Goproxy{
Logger: s.logger,
Fetcher: fetcher,
Cacher: &goproxyCacher{
cache: cache,
},
Expand Down
29 changes: 29 additions & 0 deletions internal/strategy/gomod/matcher.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package gomod

import (
"path"
"strings"
)

type ModulePathMatcher struct {
patterns []string
}

func NewModulePathMatcher(patterns []string) *ModulePathMatcher {
return &ModulePathMatcher{patterns: patterns}
}

func (m *ModulePathMatcher) IsPrivate(modulePath string) bool {
for _, pattern := range m.patterns {
matched, err := path.Match(pattern, modulePath)
if err == nil && matched {
return true
}

if strings.HasPrefix(modulePath, pattern+"/") || modulePath == pattern {
return true
}
}

return false
}
123 changes: 123 additions & 0 deletions internal/strategy/gomod/matcher_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package gomod_test

import (
"testing"

"github.com/block/cachew/internal/strategy/gomod"
)

func TestModulePathMatcher(t *testing.T) {
tests := []struct {
name string
patterns []string
modulePath string
want bool
}{
{
name: "exact match single pattern",
patterns: []string{"github.com/squareup"},
modulePath: "github.com/squareup",
want: true,
},
{
name: "exact match with multiple patterns",
patterns: []string{"github.com/org1", "github.com/squareup", "github.com/org2"},
modulePath: "github.com/squareup",
want: true,
},
{
name: "prefix match - one level deep",
patterns: []string{"github.com/squareup"},
modulePath: "github.com/squareup/repo",
want: true,
},
{
name: "prefix match - two levels deep",
patterns: []string{"github.com/squareup"},
modulePath: "github.com/squareup/repo/submodule",
want: true,
},
{
name: "prefix match with multiple patterns",
patterns: []string{"github.com/org1", "github.com/squareup"},
modulePath: "github.com/squareup/repo",
want: true,
},
{
name: "wildcard match",
patterns: []string{"github.com/squareup/*"},
modulePath: "github.com/squareup/repo",
want: true,
},
{
name: "wildcard match - multiple levels",
patterns: []string{"github.com/*/*"},
modulePath: "github.com/squareup/repo",
want: true,
},
{
name: "no match - different org",
patterns: []string{"github.com/squareup"},
modulePath: "github.com/other/repo",
want: false,
},
{
name: "no match - different host",
patterns: []string{"github.com/squareup"},
modulePath: "gitlab.com/squareup/repo",
want: false,
},
{
name: "no match - prefix without slash",
patterns: []string{"github.com/square"},
modulePath: "github.com/squareup/repo",
want: false,
},
{
name: "no match - empty patterns",
patterns: []string{},
modulePath: "github.com/squareup/repo",
want: false,
},
{
name: "empty module path",
patterns: []string{"github.com/squareup"},
modulePath: "",
want: false,
},
{
name: "multiple patterns with no match",
patterns: []string{"github.com/org1", "github.com/org2", "github.com/org3"},
modulePath: "github.com/squareup/repo",
want: false,
},
{
name: "pattern with trailing slash",
patterns: []string{"github.com/squareup/"},
modulePath: "github.com/squareup/repo",
want: false,
},
{
name: "gopkg.in pattern",
patterns: []string{"gopkg.in/square"},
modulePath: "gopkg.in/square/go-jose.v2",
want: true,
},
{
name: "nested GitHub org pattern",
patterns: []string{"github.com/squareup/internal"},
modulePath: "github.com/squareup/internal/auth",
want: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
matcher := gomod.NewModulePathMatcher(tt.patterns)
got := matcher.IsPrivate(tt.modulePath)
if got != tt.want {
t.Errorf("IsPrivate() = %v, want %v", got, tt.want)
}
})
}
}
Loading
Loading