Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
22 changes: 21 additions & 1 deletion internal/plugin/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
151 changes: 151 additions & 0 deletions internal/plugin/git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
package plugin

import (
"os"
"os/exec"
"path/filepath"
"testing"

"go.jetify.com/devbox/nix/flake"
Expand Down Expand Up @@ -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")
}
}
2 changes: 1 addition & 1 deletion internal/plugin/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading