Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
102 changes: 102 additions & 0 deletions internal/plugin/local_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package plugin

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

"go.jetify.com/devbox/nix/flake"
)

// newTestLocalPlugin writes a plugin.json (with the given create_files content
// file) into a temp dir and returns a *LocalPlugin pointing at it along with the
// path of the referenced content file.
func newTestLocalPlugin(t *testing.T, fileContents string) (*LocalPlugin, string) {
t.Helper()
pluginDir := t.TempDir()

pluginJSON := `{
"name": "testplugin",
"create_files": {
"{{ .Virtenv }}/test.txt": "test.txt"
}
}`
if err := os.WriteFile(filepath.Join(pluginDir, pluginConfigName), []byte(pluginJSON), 0o644); err != nil {
t.Fatal(err)
}

contentPath := filepath.Join(pluginDir, "test.txt")
if err := os.WriteFile(contentPath, []byte(fileContents), 0o644); err != nil {
t.Fatal(err)
}

local, err := newLocalPlugin(flake.Ref{Type: flake.TypePath, Path: pluginDir}, pluginDir)
if err != nil {
t.Fatal(err)
}
return local, contentPath
}

// TestLocalPluginHashIncludesCreateFilesContent verifies that editing a file
// referenced by a local plugin's create_files changes the plugin config hash,
// even though devbox.json and the plugin.json are unchanged. This is what allows
// Devbox to detect the change and regenerate the file in the virtenv.
// See https://github.com/jetify-com/devbox/issues/2755.
func TestLocalPluginHashIncludesCreateFilesContent(t *testing.T) {
local, contentPath := newTestLocalPlugin(t, "123")

pluginJSON, err := local.Fetch()
if err != nil {
t.Fatal(err)
}
cfg, err := buildConfig(local, filepath.Dir(contentPath), string(pluginJSON))
if err != nil {
t.Fatal(err)
}

before, err := cfg.Hash()
if err != nil {
t.Fatal(err)
}

// Edit the referenced content file without touching plugin.json.
if err := os.WriteFile(contentPath, []byte("456"), 0o644); err != nil {
t.Fatal(err)
}

after, err := cfg.Hash()
if err != nil {
t.Fatal(err)
}

if before == after {
t.Errorf("hash did not change after editing create_files content: %q", before)
}
}

// TestLocalPluginHashStableWithoutContentChange verifies the hash is stable when
// the referenced files don't change.
func TestLocalPluginHashStableWithoutContentChange(t *testing.T) {
local, contentPath := newTestLocalPlugin(t, "123")

pluginJSON, err := local.Fetch()
if err != nil {
t.Fatal(err)
}
cfg, err := buildConfig(local, filepath.Dir(contentPath), string(pluginJSON))
if err != nil {
t.Fatal(err)
}

first, err := cfg.Hash()
if err != nil {
t.Fatal(err)
}
second, err := cfg.Hash()
if err != nil {
t.Fatal(err)
}
if first != second {
t.Errorf("hash is not stable: %q != %q", first, second)
}
}
48 changes: 48 additions & 0 deletions internal/plugin/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (

"github.com/pkg/errors"
"github.com/tailscale/hujson"
"go.jetify.com/devbox/internal/cachehash"
"go.jetify.com/devbox/internal/devconfig/configfile"
"go.jetify.com/devbox/internal/devpkg"
"go.jetify.com/devbox/internal/lock"
Expand Down Expand Up @@ -70,6 +71,53 @@ func (c *Config) Services() (services.Services, error) {
return nil, nil
}

// Hash returns a hash of the plugin config. It shadows the embedded
// configfile.ConfigFile.Hash() so that, for local plugins, the contents of the
// files referenced by create_files are folded into the hash.
//
// Devbox uses this hash (via Devbox.ConfigHash) to decide whether the cached
// environment is still up to date. Local plugin files can be edited without any
// corresponding change to devbox.json or the lockfile, so without this a local
// plugin's create_files would never be regenerated in the virtenv after the
// source files change. See https://github.com/jetify-com/devbox/issues/2755.
//
// Built-in plugin files are embedded in the Devbox binary (covered by the
// Devbox version in the state hash) and remote plugins are addressed by an
// immutable ref, so for those the config hash alone is sufficient.
func (c *Config) Hash() (string, error) {
configHash, err := c.ConfigFile.Hash()
if err != nil {
return "", err
}

local, ok := c.Source.(*LocalPlugin)
if !ok {
return configHash, nil
}

buf := bytes.Buffer{}
buf.WriteString(configHash)

// Sort the content paths so the resulting hash is deterministic.
contentPaths := make([]string, 0, len(c.CreateFiles))
for _, contentPath := range c.CreateFiles {
if contentPath != "" {
contentPaths = append(contentPaths, contentPath)
}
}
slices.Sort(contentPaths)

for _, contentPath := range contentPaths {
content, err := local.FileContent(contentPath)
if err != nil {
return "", errors.WithStack(err)
}
buf.Write(content)
}

return cachehash.Bytes(buf.Bytes()), nil
}

func (m *Manager) CreateFilesForConfig(cfg *Config) error {
virtenvPath := filepath.Join(m.ProjectDir(), VirtenvPath)
pkg := cfg.Source
Expand Down
Loading