Skip to content

Commit f82a364

Browse files
authored
Add filecache to git plugin FileContent with configurable TTL (#2798)
1 parent b589a11 commit f82a364

File tree

3 files changed

+173
-2
lines changed

3 files changed

+173
-2
lines changed

internal/plugin/git.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,14 @@ import (
99
"os/exec"
1010
"path/filepath"
1111
"strings"
12+
"time"
1213

1314
"go.jetify.com/devbox/nix/flake"
15+
"go.jetify.com/pkg/filecache"
1416
)
1517

18+
var gitCache = filecache.New[[]byte]("devbox/plugin/git")
19+
1620
type gitPlugin struct {
1721
ref *flake.Ref
1822
name string
@@ -186,7 +190,23 @@ func (p *gitPlugin) Hash() string {
186190
}
187191

188192
func (p *gitPlugin) FileContent(subpath string) ([]byte, error) {
189-
return p.cloneAndRead(subpath)
193+
ttl := 24 * time.Hour
194+
var err error
195+
ttlStr := os.Getenv("DEVBOX_X_GITHUB_PLUGIN_CACHE_TTL")
196+
if ttlStr != "" {
197+
ttl, err = time.ParseDuration(ttlStr)
198+
if err != nil {
199+
return nil, fmt.Errorf("invalid DEVBOX_X_GITHUB_PLUGIN_CACHE_TTL=%q: %w", ttlStr, err)
200+
}
201+
}
202+
cacheKey := p.LockfileKey() + "/" + subpath + "/" + ttl.String()
203+
return gitCache.GetOrSet(cacheKey, func() ([]byte, time.Duration, error) {
204+
content, err := p.cloneAndRead(subpath)
205+
if err != nil {
206+
return nil, 0, err
207+
}
208+
return content, ttl, nil
209+
})
190210
}
191211

192212
func (p *gitPlugin) LockfileKey() string {

internal/plugin/git_test.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
package plugin
55

66
import (
7+
"os"
8+
"os/exec"
9+
"path/filepath"
710
"testing"
811

912
"go.jetify.com/devbox/nix/flake"
@@ -399,3 +402,151 @@ func TestIsSSHURL(t *testing.T) {
399402
})
400403
}
401404
}
405+
406+
// setupLocalGitRepo creates a temporary bare git repo with a plugin.json file.
407+
// Returns the file:// URL to the repo.
408+
func setupLocalGitRepo(t *testing.T, content string) string {
409+
t.Helper()
410+
411+
if _, err := exec.LookPath("git"); err != nil {
412+
t.Skip("skipping: git not found in PATH")
413+
}
414+
415+
// Create a working repo, commit a file, then clone it as bare.
416+
workDir := t.TempDir()
417+
runGit := func(args ...string) {
418+
t.Helper()
419+
cmd := exec.Command("git", args...)
420+
cmd.Dir = workDir
421+
cmd.Env = append(os.Environ(),
422+
"GIT_AUTHOR_NAME=test",
423+
"GIT_AUTHOR_EMAIL=test@test.com",
424+
"GIT_COMMITTER_NAME=test",
425+
"GIT_COMMITTER_EMAIL=test@test.com",
426+
)
427+
out, err := cmd.CombinedOutput()
428+
if err != nil {
429+
t.Fatalf("git %v failed: %v\n%s", args, err, out)
430+
}
431+
}
432+
433+
runGit("init")
434+
runGit("checkout", "-b", "main")
435+
if err := os.WriteFile(filepath.Join(workDir, "plugin.json"), []byte(content), 0o644); err != nil {
436+
t.Fatal(err)
437+
}
438+
runGit("add", "plugin.json")
439+
runGit("commit", "-m", "init")
440+
441+
// Clone to bare repo so file:// clone works cleanly.
442+
bareDir := t.TempDir()
443+
cmd := exec.Command("git", "clone", "--bare", workDir, bareDir)
444+
if out, err := cmd.CombinedOutput(); err != nil {
445+
t.Fatalf("bare clone failed: %v\n%s", err, out)
446+
}
447+
448+
return "file://" + bareDir
449+
}
450+
451+
func TestGitPluginFileContentCache(t *testing.T) {
452+
// Clear the git cache before and after the test to avoid pollution.
453+
if err := gitCache.Clear(); err != nil {
454+
t.Fatal(err)
455+
}
456+
t.Cleanup(func() { _ = gitCache.Clear() })
457+
458+
repoURL := setupLocalGitRepo(t, `{"name": "test-plugin"}`)
459+
460+
plugin := &gitPlugin{
461+
ref: &flake.Ref{
462+
Type: flake.TypeGit,
463+
URL: repoURL,
464+
Ref: "main",
465+
},
466+
name: "test-cache-plugin",
467+
}
468+
469+
// First call — populates the cache via git clone.
470+
content1, err := plugin.FileContent("plugin.json")
471+
if err != nil {
472+
t.Fatalf("first FileContent call failed: %v", err)
473+
}
474+
if string(content1) != `{"name": "test-plugin"}` {
475+
t.Fatalf("unexpected content: %s", content1)
476+
}
477+
478+
// Delete the source repo. If the cache is working, FileContent should
479+
// still return the cached value without attempting a clone.
480+
repoPath := repoURL[len("file://"):]
481+
if err := os.RemoveAll(repoPath); err != nil {
482+
t.Fatalf("failed to remove repo: %v", err)
483+
}
484+
485+
content2, err := plugin.FileContent("plugin.json")
486+
if err != nil {
487+
t.Fatalf("second FileContent call should have used cache but failed: %v", err)
488+
}
489+
if string(content2) != string(content1) {
490+
t.Fatalf("cached content mismatch: got %s, want %s", content2, content1)
491+
}
492+
}
493+
494+
func TestGitPluginFileContentCacheRespectsEnvVar(t *testing.T) {
495+
if err := gitCache.Clear(); err != nil {
496+
t.Fatal(err)
497+
}
498+
t.Cleanup(func() { _ = gitCache.Clear() })
499+
500+
repoURL := setupLocalGitRepo(t, `{"name": "ttl-test"}`)
501+
502+
plugin := &gitPlugin{
503+
ref: &flake.Ref{
504+
Type: flake.TypeGit,
505+
URL: repoURL,
506+
Ref: "main",
507+
},
508+
name: "test-ttl-plugin",
509+
}
510+
511+
// Set a very short TTL so the cache expires immediately.
512+
t.Setenv("DEVBOX_X_GITHUB_PLUGIN_CACHE_TTL", "1ns")
513+
514+
content, err := plugin.FileContent("plugin.json")
515+
if err != nil {
516+
t.Fatalf("FileContent failed: %v", err)
517+
}
518+
if string(content) != `{"name": "ttl-test"}` {
519+
t.Fatalf("unexpected content: %s", content)
520+
}
521+
522+
// With a 1ns TTL the cache entry should already be expired.
523+
// Delete the source repo — if the expired cache is not served,
524+
// this will attempt a fresh clone and fail, proving the TTL works.
525+
repoPath := repoURL[len("file://"):]
526+
if err := os.RemoveAll(repoPath); err != nil {
527+
t.Fatalf("failed to remove repo: %v", err)
528+
}
529+
_, err = plugin.FileContent("plugin.json")
530+
if err == nil {
531+
t.Fatal("expected error after cache expiry with deleted repo, but got nil")
532+
}
533+
}
534+
535+
func TestGitPluginFileContentCacheInvalidTTL(t *testing.T) {
536+
t.Setenv("DEVBOX_X_GITHUB_PLUGIN_CACHE_TTL", "not-a-duration")
537+
t.Cleanup(func() { _ = gitCache.Clear() })
538+
539+
plugin := &gitPlugin{
540+
ref: &flake.Ref{
541+
Type: flake.TypeGit,
542+
URL: "file:///doesnt-matter",
543+
Ref: "main",
544+
},
545+
name: "test-invalid-ttl",
546+
}
547+
548+
_, err := plugin.FileContent("plugin.json")
549+
if err == nil {
550+
t.Fatal("expected error for invalid TTL, got nil")
551+
}
552+
}

internal/plugin/github.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ func (p *githubPlugin) FileContent(subpath string) ([]byte, error) {
9191
}
9292

9393
return githubCache.GetOrSet(
94-
contentURL+ttlStr,
94+
contentURL+ttl.String(),
9595
func() ([]byte, time.Duration, error) {
9696
req, err := p.request(contentURL)
9797
if err != nil {

0 commit comments

Comments
 (0)