Skip to content

Commit 8b00b5b

Browse files
r00tdaemonsavil
andauthored
Added support for ZDOTDIR handling in Zsh shell initialization. (#2712)
## Summary Fixes #1715 #2297 This commit adds a patch to correctly handle ZDOTDIR env var for zsh. Instead of copying .z* files directly, we now source them inside devbox zsh config after setting ZDOTDIR to user's config dir. This makes sure any user config referencing ZDOTDIR doesn't break. ## How was it tested? Unit tests and manual testing ## Community Contribution License All community contributions in this pull request are licensed to the project maintainers under the terms of the [Apache 2 License](https://www.apache.org/licenses/LICENSE-2.0). By creating this pull request, I represent that I have the right to license the contributions to the project maintainers under the Apache 2 License as stated in the [Community Contribution License](https://github.com/jetify-com/opensource/blob/main/CONTRIBUTING.md#community-contribution-license). --------- Co-authored-by: Savil Srivastava <676452+savil@users.noreply.github.com>
1 parent e8dfb82 commit 8b00b5b

File tree

6 files changed

+377
-14
lines changed

6 files changed

+377
-14
lines changed

internal/devbox/shell.go

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import (
2929

3030
//go:embed shellrc.tmpl
3131
var shellrcText string
32-
var shellrcTmpl = template.Must(template.New("shellrc").Parse(shellrcText))
32+
var shellrcTmpl = template.Must(template.New("shellrc").Funcs(template.FuncMap{"dirPath": filepath.Dir}).Parse(shellrcText))
3333

3434
//go:embed shellrc_fish.tmpl
3535
var fishrcText string
@@ -228,8 +228,8 @@ func (s *DevboxShell) Run() error {
228228
return errors.WithStack(err)
229229
}
230230

231-
// Link other files that affect the shell settings and environments.
232-
s.linkShellStartupFiles(filepath.Dir(shellrc))
231+
// Setup other files that affect the shell settings and environments.
232+
s.setupShellStartupFiles(filepath.Dir(shellrc))
233233
extraEnv, extraArgs := s.shellRCOverrides(shellrc)
234234
env := s.env
235235
for k, v := range extraEnv {
@@ -323,6 +323,7 @@ func (s *DevboxShell) writeDevboxShellrc() (path string, err error) {
323323
ShellStartTime string
324324
HistoryFile string
325325
ExportEnv string
326+
ShellName string
326327

327328
RefreshAliasName string
328329
RefreshCmd string
@@ -335,6 +336,7 @@ func (s *DevboxShell) writeDevboxShellrc() (path string, err error) {
335336
ShellStartTime: telemetry.FormatShellStart(s.shellStartTime),
336337
HistoryFile: strings.TrimSpace(s.historyFile),
337338
ExportEnv: exportify(s.env),
339+
ShellName: string(s.name),
338340
RefreshAliasName: s.devbox.refreshAliasName(),
339341
RefreshCmd: s.devbox.refreshCmd(),
340342
RefreshAliasEnvVar: s.devbox.refreshAliasEnvVar(),
@@ -347,12 +349,13 @@ func (s *DevboxShell) writeDevboxShellrc() (path string, err error) {
347349
return path, nil
348350
}
349351

350-
// linkShellStartupFiles will link files used by the shell for initialization.
351-
// We choose to link instead of copy so that changes made outside can be reflected
352-
// within the devbox shell.
352+
// setupShellStartupFiles creates initialization files for the shell by sourcing the user's originals.
353+
// We do this instead of linking or copying, so that we can set correct ZDOTDIR when sourcing
354+
// user's config files which may use the ZDOTDIR env var inside them.
355+
// This also allows us to make sure any devbox config is run after correctly sourcing the user's config.
353356
//
354357
// We do not link the .{shell}rc files, since devbox modifies them. See writeDevboxShellrc
355-
func (s *DevboxShell) linkShellStartupFiles(shellSettingsDir string) {
358+
func (s *DevboxShell) setupShellStartupFiles(shellSettingsDir string) {
356359
// For now, we only need to do this for zsh shell
357360
if s.name == shZsh {
358361
// List of zsh startup files: https://zsh.sourceforge.io/Intro/intro_3.html
@@ -364,21 +367,52 @@ func (s *DevboxShell) linkShellStartupFiles(shellSettingsDir string) {
364367

365368
for _, filename := range filenames {
366369
// The userShellrcPath should be set to ZDOTDIR already.
367-
fileOld := filepath.Join(filepath.Dir(s.userShellrcPath), filename)
368-
_, err := os.Stat(fileOld)
370+
userFile := filepath.Join(filepath.Dir(s.userShellrcPath), filename)
371+
_, err := os.Stat(userFile)
369372
if errors.Is(err, fs.ErrNotExist) {
370373
// this file may not be relevant for the user's setup.
371374
continue
372375
}
373376
if err != nil {
374-
slog.Debug("os.Stat error for %s is %v", fileOld, err)
377+
slog.Debug("os.Stat error for %s is %v", userFile, err)
375378
}
376379

377380
fileNew := filepath.Join(shellSettingsDir, filename)
378-
cmd := exec.Command("cp", fileOld, fileNew)
379-
if err := cmd.Run(); err != nil {
380-
// This is a best-effort operation. If there's an error then log it for visibility but continue.
381-
slog.Error("error copying zsh setting file", "from", fileOld, "to", fileNew, "err", err)
381+
382+
// Create template content that sources the original file
383+
templateContent := `if [[ -f "{{.UserFile}}" ]]; then
384+
local DEVBOX_ZDOTDIR="$ZDOTDIR"
385+
export ZDOTDIR="{{.ZDOTDIR}}"
386+
. "{{.UserFile}}"
387+
export ZDOTDIR="$DEVBOX_ZDOTDIR"
388+
fi`
389+
390+
// Parse and execute the template
391+
tmpl, err := template.New("shellrc").Parse(templateContent)
392+
if err != nil {
393+
slog.Error("error parsing template for zsh setting file", "filename", filename, "err", err)
394+
continue
395+
}
396+
397+
// Create the new file with template content
398+
file, err := os.Create(fileNew)
399+
if err != nil {
400+
slog.Error("error creating zsh setting file", "filename", filename, "err", err)
401+
continue
402+
}
403+
defer file.Close()
404+
405+
// Execute template with data
406+
data := struct {
407+
UserFile string
408+
ZDOTDIR string
409+
}{
410+
UserFile: userFile,
411+
ZDOTDIR: filepath.Dir(s.userShellrcPath),
412+
}
413+
414+
if err := tmpl.Execute(file, data); err != nil {
415+
slog.Error("error executing template for zsh setting file", "filename", filename, "err", err)
382416
continue
383417
}
384418
}

internal/devbox/shell_test.go

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"go.jetify.com/devbox/internal/devbox/devopt"
1818
"go.jetify.com/devbox/internal/envir"
1919
"go.jetify.com/devbox/internal/shellgen"
20+
"go.jetify.com/devbox/internal/xdg"
2021
)
2122

2223
// updateFlag overwrites golden files with the new test results.
@@ -72,6 +73,10 @@ func testWriteDevboxShellrc(t *testing.T, testdirs []string) {
7273
projectDir: "/path/to/projectDir",
7374
userShellrcPath: test.shellrcPath,
7475
}
76+
// Set shell name based on test name for zsh tests
77+
if strings.Contains(test.name, "zsh") {
78+
s.name = shZsh
79+
}
7580
gotPath, err := s.writeDevboxShellrc()
7681
if err != nil {
7782
t.Fatal("Got writeDevboxShellrc error:", err)
@@ -165,3 +170,239 @@ func TestShellPath(t *testing.T) {
165170
})
166171
}
167172
}
173+
174+
func TestInitShellBinaryFields(t *testing.T) {
175+
tests := []struct {
176+
name string
177+
path string
178+
env map[string]string
179+
expectedName name
180+
expectedRcPath string
181+
expectedRcPathBase string
182+
}{
183+
{
184+
name: "bash shell",
185+
path: "/usr/bin/bash",
186+
expectedName: shBash,
187+
expectedRcPathBase: ".bashrc",
188+
},
189+
{
190+
name: "zsh shell without ZDOTDIR",
191+
path: "/usr/bin/zsh",
192+
expectedName: shZsh,
193+
expectedRcPathBase: ".zshrc",
194+
},
195+
{
196+
name: "zsh shell with ZDOTDIR",
197+
path: "/usr/bin/zsh",
198+
env: map[string]string{
199+
"ZDOTDIR": "/custom/zsh/config",
200+
},
201+
expectedName: shZsh,
202+
expectedRcPath: "/custom/zsh/config/.zshrc",
203+
},
204+
{
205+
name: "ksh shell",
206+
path: "/usr/bin/ksh",
207+
expectedName: shKsh,
208+
expectedRcPathBase: ".kshrc",
209+
},
210+
{
211+
name: "fish shell",
212+
path: "/usr/bin/fish",
213+
expectedName: shFish,
214+
expectedRcPath: xdg.ConfigSubpath("fish/config.fish"),
215+
},
216+
{
217+
name: "dash shell",
218+
path: "/usr/bin/dash",
219+
expectedName: shPosix,
220+
expectedRcPath: ".shinit",
221+
},
222+
{
223+
name: "unknown shell",
224+
path: "/usr/bin/unknown",
225+
expectedName: shUnknown,
226+
expectedRcPathBase: "",
227+
},
228+
}
229+
230+
for _, test := range tests {
231+
t.Run(test.name, func(t *testing.T) {
232+
// Set up environment variables
233+
for k, v := range test.env {
234+
t.Setenv(k, v)
235+
}
236+
237+
shell := initShellBinaryFields(test.path)
238+
239+
if shell.name != test.expectedName {
240+
t.Errorf("Expected shell name %v, got %v", test.expectedName, shell.name)
241+
}
242+
243+
if test.expectedRcPath != "" {
244+
if shell.userShellrcPath != test.expectedRcPath {
245+
t.Errorf("Expected rc path %s, got %s", test.expectedRcPath, shell.userShellrcPath)
246+
}
247+
} else if test.expectedRcPathBase != "" {
248+
// For tests that expect a path relative to home directory,
249+
// check that the path ends with the expected basename
250+
expectedBasename := test.expectedRcPathBase
251+
actualBasename := filepath.Base(shell.userShellrcPath)
252+
if actualBasename != expectedBasename {
253+
t.Errorf("Expected rc path basename %s, got %s (full path: %s)", expectedBasename, actualBasename, shell.userShellrcPath)
254+
}
255+
}
256+
})
257+
}
258+
}
259+
260+
func TestSetupShellStartupFiles(t *testing.T) {
261+
tmpDir := t.TempDir()
262+
263+
// Create a mock zsh shell
264+
shell := &DevboxShell{
265+
name: shZsh,
266+
userShellrcPath: filepath.Join(tmpDir, ".zshrc"),
267+
}
268+
269+
// Create some test zsh startup files
270+
startupFiles := []string{".zshenv", ".zprofile", ".zlogin", ".zlogout", ".zimrc"}
271+
for _, filename := range startupFiles {
272+
filePath := filepath.Join(tmpDir, filename)
273+
err := os.WriteFile(filePath, []byte("# Test content for "+filename), 0o644)
274+
if err != nil {
275+
t.Fatalf("Failed to create test file %s: %v", filename, err)
276+
}
277+
}
278+
279+
// Create a temporary directory for shell settings
280+
shellSettingsDir := t.TempDir()
281+
282+
// Call setupShellStartupFiles
283+
shell.setupShellStartupFiles(shellSettingsDir)
284+
285+
// Check that all startup files were created in the shell settings directory
286+
for _, filename := range startupFiles {
287+
expectedPath := filepath.Join(shellSettingsDir, filename)
288+
_, err := os.Stat(expectedPath)
289+
if err != nil {
290+
t.Errorf("Expected startup file %s to be created, but got error: %v", filename, err)
291+
}
292+
293+
// Check that the file contains the expected template content
294+
content, err := os.ReadFile(expectedPath)
295+
if err != nil {
296+
t.Errorf("Failed to read created file %s: %v", filename, err)
297+
continue
298+
}
299+
300+
contentStr := string(content)
301+
expectedOldPath := filepath.Join(tmpDir, filename)
302+
if !strings.Contains(contentStr, expectedOldPath) {
303+
t.Errorf("Expected file %s to contain path %s, but content was: %s", filename, expectedOldPath, contentStr)
304+
}
305+
306+
if !strings.Contains(contentStr, "DEVBOX_ZDOTDIR") {
307+
t.Errorf("Expected file %s to contain ZDOTDIR handling, but content was: %s", filename, contentStr)
308+
}
309+
}
310+
}
311+
312+
func TestWriteDevboxShellrcBash(t *testing.T) {
313+
tmpDir := t.TempDir()
314+
315+
// Create a test bash rc file
316+
bashrcPath := filepath.Join(tmpDir, ".bashrc")
317+
bashrcContent := "# Test bash configuration\nexport TEST_VAR=value"
318+
err := os.WriteFile(bashrcPath, []byte(bashrcContent), 0o644)
319+
if err != nil {
320+
t.Fatalf("Failed to create test .bashrc: %v", err)
321+
}
322+
323+
// Create a mock devbox
324+
devbox := &Devbox{projectDir: "/test/project"}
325+
326+
// Create a bash shell
327+
shell := &DevboxShell{
328+
devbox: devbox,
329+
name: shBash,
330+
userShellrcPath: bashrcPath,
331+
projectDir: "/test/project",
332+
env: map[string]string{"TEST_ENV": "test_value"},
333+
}
334+
335+
// Write the devbox shellrc
336+
shellrcPath, err := shell.writeDevboxShellrc()
337+
if err != nil {
338+
t.Fatalf("Failed to write devbox shellrc: %v", err)
339+
}
340+
341+
// Read and verify the content
342+
content, err := os.ReadFile(shellrcPath)
343+
if err != nil {
344+
t.Fatalf("Failed to read generated shellrc: %v", err)
345+
}
346+
347+
contentStr := string(content)
348+
349+
// Check that it does NOT contain zsh-specific ZDOTDIR handling
350+
if strings.Contains(contentStr, "DEVBOX_ZDOTDIR") {
351+
t.Error("Expected shellrc to NOT contain ZDOTDIR handling for bash")
352+
}
353+
354+
// Check that it sources the original .bashrc
355+
if !strings.Contains(contentStr, bashrcPath) {
356+
t.Error("Expected shellrc to source the original .bashrc file")
357+
}
358+
}
359+
360+
func TestWriteDevboxShellrcWithZDOTDIR(t *testing.T) {
361+
tmpDir := t.TempDir()
362+
363+
// Set up ZDOTDIR environment
364+
t.Setenv("ZDOTDIR", tmpDir)
365+
366+
// Create a test zsh rc file in the custom ZDOTDIR
367+
customZshrcPath := filepath.Join(tmpDir, ".zshrc")
368+
zshrcContent := "# Custom zsh configuration\nexport CUSTOM_VAR=value"
369+
err := os.WriteFile(customZshrcPath, []byte(zshrcContent), 0o644)
370+
if err != nil {
371+
t.Fatalf("Failed to create test .zshrc: %v", err)
372+
}
373+
374+
// Create a mock devbox
375+
devbox := &Devbox{projectDir: "/test/project"}
376+
377+
// Create a zsh shell - this should pick up the ZDOTDIR
378+
shell := initShellBinaryFields("/usr/bin/zsh")
379+
shell.devbox = devbox
380+
shell.projectDir = "/test/project"
381+
382+
if shell.userShellrcPath != customZshrcPath {
383+
t.Error("Expected shellrc path to respect ZDOTDIR")
384+
}
385+
386+
// Write the devbox shellrc
387+
shellrcPath, err := shell.writeDevboxShellrc()
388+
if err != nil {
389+
t.Fatalf("Failed to write devbox shellrc: %v", err)
390+
}
391+
392+
// Read and verify the content
393+
content, err := os.ReadFile(shellrcPath)
394+
if err != nil {
395+
t.Fatalf("Failed to read generated shellrc: %v", err)
396+
}
397+
398+
contentStr := string(content)
399+
// Check that it contains zsh-specific ZDOTDIR handling
400+
if !strings.Contains(contentStr, "DEVBOX_ZDOTDIR") {
401+
t.Error("Expected shellrc to contain ZDOTDIR handling for zsh")
402+
}
403+
404+
// Check that it sources the custom .zshrc
405+
if !strings.Contains(contentStr, customZshrcPath) {
406+
t.Error("Expected shellrc to source the custom .zshrc file")
407+
}
408+
}

internal/devbox/shellrc.tmpl

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,18 @@ content readable.
1919
*/ -}}
2020

2121
{{- if .OriginalInitPath -}}
22+
{{- if eq .ShellName "zsh" -}}
2223
if [ -f {{ .OriginalInitPath }} ]; then
24+
local DEVBOX_ZDOTDIR="$ZDOTDIR"
25+
export ZDOTDIR="{{dirPath .OriginalInitPath}}"
2326
. "{{ .OriginalInitPath }}"
27+
export ZDOTDIR="$DEVBOX_ZDOTDIR"
2428
fi
29+
{{ else -}}
30+
if [ -f {{ .OriginalInitPath }} ]; then
31+
. "{{ .OriginalInitPath }}"
32+
fi
33+
{{ end -}}
2534
{{ end -}}
2635

2736
# Begin Devbox Post-init Hook
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
simple=value
2+
space=quote me
3+
quote=they said, "lasers"
4+
special=$`"\
5+
BASH_FUNC_echo_simple%%=() { echo "${simple}"; }

0 commit comments

Comments
 (0)