diff --git a/internal/plugin/git.go b/internal/plugin/git.go index ab78867a40a..2244a3807ce 100644 --- a/internal/plugin/git.go +++ b/internal/plugin/git.go @@ -9,10 +9,14 @@ import ( "os/exec" "path/filepath" "strings" + "time" "go.jetify.com/devbox/nix/flake" + "go.jetify.com/pkg/filecache" ) +var gitCache = filecache.New[[]byte]("devbox/plugin/git") + type gitPlugin struct { ref *flake.Ref name string @@ -186,7 +190,23 @@ func (p *gitPlugin) Hash() string { } func (p *gitPlugin) FileContent(subpath string) ([]byte, error) { - return p.cloneAndRead(subpath) + ttl := 24 * time.Hour + var err error + ttlStr := os.Getenv("DEVBOX_X_GITHUB_PLUGIN_CACHE_TTL") + if ttlStr != "" { + ttl, err = time.ParseDuration(ttlStr) + if err != nil { + return nil, fmt.Errorf("invalid DEVBOX_X_GITHUB_PLUGIN_CACHE_TTL=%q: %w", ttlStr, err) + } + } + cacheKey := p.LockfileKey() + "/" + subpath + "/" + ttl.String() + return gitCache.GetOrSet(cacheKey, func() ([]byte, time.Duration, error) { + content, err := p.cloneAndRead(subpath) + if err != nil { + return nil, 0, err + } + return content, ttl, nil + }) } func (p *gitPlugin) LockfileKey() string { diff --git a/internal/plugin/git_test.go b/internal/plugin/git_test.go index 0d56b6dc604..4107a894a32 100644 --- a/internal/plugin/git_test.go +++ b/internal/plugin/git_test.go @@ -4,6 +4,9 @@ package plugin import ( + "os" + "os/exec" + "path/filepath" "testing" "go.jetify.com/devbox/nix/flake" @@ -399,3 +402,151 @@ func TestIsSSHURL(t *testing.T) { }) } } + +// setupLocalGitRepo creates a temporary bare git repo with a plugin.json file. +// Returns the file:// URL to the repo. +func setupLocalGitRepo(t *testing.T, content string) string { + t.Helper() + + if _, err := exec.LookPath("git"); err != nil { + t.Skip("skipping: git not found in PATH") + } + + // Create a working repo, commit a file, then clone it as bare. + workDir := t.TempDir() + runGit := func(args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = workDir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", + "GIT_AUTHOR_EMAIL=test@test.com", + "GIT_COMMITTER_NAME=test", + "GIT_COMMITTER_EMAIL=test@test.com", + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, out) + } + } + + runGit("init") + runGit("checkout", "-b", "main") + if err := os.WriteFile(filepath.Join(workDir, "plugin.json"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + runGit("add", "plugin.json") + runGit("commit", "-m", "init") + + // Clone to bare repo so file:// clone works cleanly. + bareDir := t.TempDir() + cmd := exec.Command("git", "clone", "--bare", workDir, bareDir) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("bare clone failed: %v\n%s", err, out) + } + + return "file://" + bareDir +} + +func TestGitPluginFileContentCache(t *testing.T) { + // Clear the git cache before and after the test to avoid pollution. + if err := gitCache.Clear(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = gitCache.Clear() }) + + repoURL := setupLocalGitRepo(t, `{"name": "test-plugin"}`) + + plugin := &gitPlugin{ + ref: &flake.Ref{ + Type: flake.TypeGit, + URL: repoURL, + Ref: "main", + }, + name: "test-cache-plugin", + } + + // First call — populates the cache via git clone. + content1, err := plugin.FileContent("plugin.json") + if err != nil { + t.Fatalf("first FileContent call failed: %v", err) + } + if string(content1) != `{"name": "test-plugin"}` { + t.Fatalf("unexpected content: %s", content1) + } + + // Delete the source repo. If the cache is working, FileContent should + // still return the cached value without attempting a clone. + repoPath := repoURL[len("file://"):] + if err := os.RemoveAll(repoPath); err != nil { + t.Fatalf("failed to remove repo: %v", err) + } + + content2, err := plugin.FileContent("plugin.json") + if err != nil { + t.Fatalf("second FileContent call should have used cache but failed: %v", err) + } + if string(content2) != string(content1) { + t.Fatalf("cached content mismatch: got %s, want %s", content2, content1) + } +} + +func TestGitPluginFileContentCacheRespectsEnvVar(t *testing.T) { + if err := gitCache.Clear(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = gitCache.Clear() }) + + repoURL := setupLocalGitRepo(t, `{"name": "ttl-test"}`) + + plugin := &gitPlugin{ + ref: &flake.Ref{ + Type: flake.TypeGit, + URL: repoURL, + Ref: "main", + }, + name: "test-ttl-plugin", + } + + // Set a very short TTL so the cache expires immediately. + t.Setenv("DEVBOX_X_GITHUB_PLUGIN_CACHE_TTL", "1ns") + + content, err := plugin.FileContent("plugin.json") + if err != nil { + t.Fatalf("FileContent failed: %v", err) + } + if string(content) != `{"name": "ttl-test"}` { + t.Fatalf("unexpected content: %s", content) + } + + // With a 1ns TTL the cache entry should already be expired. + // Delete the source repo — if the expired cache is not served, + // this will attempt a fresh clone and fail, proving the TTL works. + repoPath := repoURL[len("file://"):] + if err := os.RemoveAll(repoPath); err != nil { + t.Fatalf("failed to remove repo: %v", err) + } + _, err = plugin.FileContent("plugin.json") + if err == nil { + t.Fatal("expected error after cache expiry with deleted repo, but got nil") + } +} + +func TestGitPluginFileContentCacheInvalidTTL(t *testing.T) { + t.Setenv("DEVBOX_X_GITHUB_PLUGIN_CACHE_TTL", "not-a-duration") + t.Cleanup(func() { _ = gitCache.Clear() }) + + plugin := &gitPlugin{ + ref: &flake.Ref{ + Type: flake.TypeGit, + URL: "file:///doesnt-matter", + Ref: "main", + }, + name: "test-invalid-ttl", + } + + _, err := plugin.FileContent("plugin.json") + if err == nil { + t.Fatal("expected error for invalid TTL, got nil") + } +} diff --git a/internal/plugin/github.go b/internal/plugin/github.go index b3c14f9f2c3..a587b7642b1 100644 --- a/internal/plugin/github.go +++ b/internal/plugin/github.go @@ -91,7 +91,7 @@ func (p *githubPlugin) FileContent(subpath string) ([]byte, error) { } return githubCache.GetOrSet( - contentURL+ttlStr, + contentURL+ttl.String(), func() ([]byte, time.Duration, error) { req, err := p.request(contentURL) if err != nil {