Handling environment variables in Emacs on OSX and Linux
I read the post A Couple Of Tips For Emacs on macOS, where Irreal covers JTRs Emacs configuration series, and they raise two pain points on OSX. One of the tips is how to ensure environment variables are properly set inside Emacs, by using exec-path-from-shell. So I thought I would share how I deal with it.
On OSX, Emacs is launched from Finder or from the Dock rather than a shell, so it doesn’t inherit your shell’s environment the way terminal Emacs would. The same can happen with Emacs on Linux if you’re not spawning the process from a shell, or if your application launcher isn’t configured to invoke from the shell.
One of the issues I had with exec-path-from-shell is that it was
still slow even after I configured my shell to expose the environment
variables in a non-interactive shell. I also didn’t want Emacs to
invoke a subprocess at startup. So I did something similar to how Doom
Emacs does: I now generate a file with all the environment variables I
want, and make Emacs read that file and load these variables.
In my early-init.el, I added this code:
;; Load shell env snapshot generated by emacs-refresh-env.
;; Mirrors Doom's approach: snapshot is written once by a script running
;; `zsh -ic env`, so no subprocess is needed at startup.
(let ((env-file (expand-file-name "env" user-emacs-directory)))
(when (file-readable-p env-file)
(dolist (line (split-string (with-temp-buffer
(insert-file-contents env-file)
(buffer-string))
"\n" t))
(when (string-match "^\\([^=\n]+\\)=\\(.*\\)$" line)
(setenv (match-string 1 line) (match-string 2 line))))
(when-let* ((path (getenv "PATH")))
(setq exec-path (append (split-string path ":") (list exec-directory))))))Then I created a script, emacs-refresh-env, which copies the
environment variables from my shell and puts them into the env file,
which Emacs reads. The script ignores some environment variables and
filters out Tmux/Alacritty-generated noise.
#!/usr/bin/env zsh
# emacs-refresh-env: regenerate the Emacs shell env snapshot.
# Run after changing .zshenv or .zshrc.
set -euo pipefail
emacs_d="$HOME/.emacs.d"
out="${emacs_d}/env"
# Vars to omit from the Emacs env snapshot (shell bookkeeping, etc.).
exclude=(
_
SHLVL
PWD
OLDPWD
DIRENV_DIFF
DIRENV_DIR
DIRENV_FILE
DIRENV_WATCHES
EDITOR
VISUAL
LS_COLORS
ALACRITTY_LOG
)
pattern="^(${(j:|:)exclude})="
# Drop excluded vars and tmux/terminal garbage (e.g. leaked \033Ptmux;…ALACRITTY_LOG=…).
zsh -ic env </dev/null |
grep -E '^[A-Za-z_][A-Za-z0-9_]*=' |
grep -Ev "$pattern" |
sort >"$out"
echo "Wrote $(wc -l <"$out" | tr -d ' ') vars to $out"Once the script executes, it creates the env file inside my
user-emacs-directory and early-init.el picks it up when starting
Emacs.
The benefit of this is that there is no external dependency on
exec-path-from-shell anymore, and it’s fast, but the trade-off is
you need to always make sure you generate the env file in case your
environment variables change.
I have an update-all script which updates my machine, along with
some other dependencies, so I hooked emacs-refresh-env into it,
ensuring it runs periodically and picks up new environment variables.