Repository: jetpack-io/devbox
Pull request #1709 (2024-01-09 01:11:23)

[init-hooks] Prevent init hook recursion
## Summary

Fixes https://github.com/jetpack-io/devbox/issues/1651

## How was it tested?

Added a `devbox run echo foobar` into our init hook. Tested with

```
devbox run echo 123
SHELL=fish devbox run echo 123
```




Patch:
diff --git a/internal/devbox/devbox.go b/internal/devbox/devbox.go
index 28b3c200..f00a2838 100644
--- a/internal/devbox/devbox.go
+++ b/internal/devbox/devbox.go
@@ -338,7 +338,7 @@ func (d *Devbox) EnvVars(ctx context.Context) ([]string, error) {
 
 func (d *Devbox) shellEnvHashKey() string {
 	// Don't make this a const so we don't use it by itself accidentally
-	return "__DEVBOX_SHELLENV_HASH_" + d.projectDirHash()
+	return "__DEVBOX_SHELLENV_HASH_" + d.ProjectDirHash()
 }
 
 func (d *Devbox) Info(ctx context.Context, pkg string, markdown bool) (string, error) {
@@ -949,7 +949,7 @@ func (d *Devbox) computeEnv(ctx context.Context, usePrintDevEnvCache bool) (map[
 	devboxEnvPath = envpath.JoinPathLists(devboxEnvPath, runXPaths)
 
 	pathStack := envpath.Stack(env, originalEnv)
-	pathStack.Push(env, d.projectDirHash(), devboxEnvPath, d.preservePathStack)
+	pathStack.Push(env, d.ProjectDirHash(), devboxEnvPath, d.preservePathStack)
 	env["PATH"] = pathStack.Path(env)
 	debug.Log("New path stack is: %s", pathStack)
 
@@ -1170,7 +1170,7 @@ func (d *Devbox) setCommonHelperEnvVars(env map[string]string) {
 	env["LIBRARY_PATH"] = envpath.JoinPathLists(profileLibDir, env["LIBRARY_PATH"])
 }
 
-func (d *Devbox) projectDirHash() string {
+func (d *Devbox) ProjectDirHash() string {
 	h, _ := cachehash.Bytes([]byte(d.projectDir))
 	return h
 }
diff --git a/internal/devbox/envvars.go b/internal/devbox/envvars.go
index 06a37b30..1ae26091 100644
--- a/internal/devbox/envvars.go
+++ b/internal/devbox/envvars.go
@@ -76,5 +76,5 @@ func (d *Devbox) IsEnvEnabled() bool {
 	fakeEnv := map[string]string{}
 	// the Stack is initialized in the fakeEnv, from the state in the real os.Environ
 	pathStack := envpath.Stack(fakeEnv, envir.PairsToMap(os.Environ()))
-	return pathStack.Has(d.projectDirHash())
+	return pathStack.Has(d.ProjectDirHash())
 }
diff --git a/internal/devbox/refresh.go b/internal/devbox/refresh.go
index 6439b9a2..2b6d895c 100644
--- a/internal/devbox/refresh.go
+++ b/internal/devbox/refresh.go
@@ -15,7 +15,7 @@ func (d *Devbox) isRefreshAliasSet() bool {
 }
 
 func (d *Devbox) refreshAliasEnvVar() string {
-	return "DEVBOX_REFRESH_ALIAS_" + d.projectDirHash()
+	return "DEVBOX_REFRESH_ALIAS_" + d.ProjectDirHash()
 }
 
 func (d *Devbox) isGlobal() bool {
diff --git a/internal/shellgen/scripts.go b/internal/shellgen/scripts.go
index 2fdaa942..981e4d9b 100644
--- a/internal/shellgen/scripts.go
+++ b/internal/shellgen/scripts.go
@@ -5,6 +5,9 @@
 	"os"
 	"path/filepath"
 	"strings"
+	"text/template"
+
+	_ "embed"
 
 	"github.com/pkg/errors"
 	"go.jetpack.io/devbox/internal/boxcli/featureflag"
@@ -15,6 +18,9 @@
 	"go.jetpack.io/devbox/internal/plugin"
 )
 
+//go:embed tmpl/init-hook.tmpl
+var initHookTmpl string
+
 const scriptsDir = ".devbox/gen/scripts"
 
 // HooksFilename is the name of the file that contains the project's init-hooks and plugin hooks
@@ -27,6 +33,7 @@ type devboxer interface {
 	InstallablePackages() []*devpkg.Package
 	PluginManager() *plugin.Manager
 	ProjectDir() string
+	ProjectDirHash() string
 }
 
 // WriteScriptsToFiles writes scripts defined in devbox.json into files inside .devbox/gen/scripts.
@@ -55,7 +62,7 @@ func WriteScriptsToFiles(devbox devboxer) error {
 	}
 	hooks := strings.Join(append(pluginHooks, devbox.Config().InitHook().String()), "\n\n")
 	// always write it, even if there are no hooks, because scripts will source it.
-	err = writeHookFile(devbox, hooks)
+	err = writeInitHookFile(devbox, hooks)
 	if err != nil {
 		return errors.WithStack(err)
 	}
@@ -84,15 +91,25 @@ func WriteScriptsToFiles(devbox devboxer) error {
 	return nil
 }
 
-func writeHookFile(devbox devboxer, body string) (err error) {
+func writeInitHookFile(devbox devboxer, body string) (err error) {
 	script, err := createScriptFile(devbox, HooksFilename)
 	if err != nil {
 		return errors.WithStack(err)
 	}
 	defer script.Close() // best effort: close file
 
-	_, err = script.WriteString(body)
-	return errors.WithStack(err)
+	t, err := template.New("init-hook-template").Parse(initHookTmpl)
+	if err != nil {
+		return errors.WithStack(err)
+	}
+
+	return t.Execute(script, map[string]any{
+		"Body":         body,
+		"InitHookHash": "__DEVBOX_INIT_HOOK_" + devbox.ProjectDirHash(),
+		// TODO put IsFish() in common place so we can call here and in devbox package
+		// without adding more stuff to interface
+		"IsFish": filepath.Base(os.Getenv("SHELL")) == "fish",
+	})
 }
 
 func WriteScriptFile(devbox devboxer, name, body string) (err error) {
diff --git a/internal/shellgen/tmpl/init-hook.tmpl b/internal/shellgen/tmpl/init-hook.tmpl
new file mode 100644
index 00000000..7e817c7b
--- /dev/null
+++ b/internal/shellgen/tmpl/init-hook.tmpl
@@ -0,0 +1,15 @@
+
+{{/* if hash is set, we're in recursive loop, just exit */ -}}
+if [ -n "${{ .InitHookHash }}" ]; then
+    return
+fi
+
+export {{ .InitHookHash }}=true
+
+{{ .Body }}
+
+{{ if .IsFish }}
+    set -e {{ .InitHookHash }}
+{{ else }}
+    unset {{ .InitHookHash }}
+{{ end }}


Test patch:
diff --git a/internal/devbox/devbox_test.go b/internal/devbox/devbox_test.go
index df3e1c6b..260c1b7f 100644
--- a/internal/devbox/devbox_test.go
+++ b/internal/devbox/devbox_test.go
@@ -88,7 +88,7 @@ func TestComputeDevboxPathIsIdempotent(t *testing.T) {
 	t.Setenv("PATH", path)
 	t.Setenv(envpath.InitPathEnv, env[envpath.InitPathEnv])
 	t.Setenv(envpath.PathStackEnv, env[envpath.PathStackEnv])
-	t.Setenv(envpath.Key(devbox.projectDirHash()), env[envpath.Key(devbox.projectDirHash())])
+	t.Setenv(envpath.Key(devbox.ProjectDirHash()), env[envpath.Key(devbox.ProjectDirHash())])
 
 	env, err = devbox.computeEnv(ctx, false /*use cache*/)
 	require.NoError(t, err, "computeEnv should not fail")
@@ -110,7 +110,7 @@ func TestComputeDevboxPathWhenRemoving(t *testing.T) {
 	t.Setenv("PATH", path)
 	t.Setenv(envpath.InitPathEnv, env[envpath.InitPathEnv])
 	t.Setenv(envpath.PathStackEnv, env[envpath.PathStackEnv])
-	t.Setenv(envpath.Key(devbox.projectDirHash()), env[envpath.Key(devbox.projectDirHash())])
+	t.Setenv(envpath.Key(devbox.ProjectDirHash()), env[envpath.Key(devbox.ProjectDirHash())])
 
 	devbox.nix.(*testNix).path = ""
 	env, err = devbox.computeEnv(ctx, false /*use cache*/)