Skip to content

Commit 645c717

Browse files
committed
Add filecache to git plugin FileContent with configurable TTL
The git plugin's FileContent method was cloning the repo on every call. This adds caching using filecache (matching the github plugin pattern), with TTL controlled by DEVBOX_X_GITHUB_PLUGIN_CACHE_TTL env var. Includes tests for cache hit, TTL expiry, and invalid TTL handling.
1 parent b589a11 commit 645c717

2 files changed

Lines changed: 168 additions & 1 deletion

File tree

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

0 commit comments

Comments
 (0)