envrc-tools automatically manages per-directory environments by detecting .envrc files and loading them in isolated subshells. When you cd into a directory with .envrc, a new shell process is spawned and the .envrc is sourced inside it. When you leave, the subshell exits — the parent environment is never modified, so no cleanup is needed.
This differs from direnv, which snapshots the environment before/after sourcing .envrc and patches the diff into the current shell. The subshell approach trades a shell process per environment for zero-complexity teardown and natural nesting.
In .zshrc:
source ~/path/to/envrc.sh; envrc hook zshThis sources all functions into the current shell and registers zsh hooks (precmd, chpwd).
Only envrc.sh is in use. It is self-contained — no other files are sourced or referenced at runtime.
This is the heart of the system. The condition in _load_envrc determines the behavior:
_load_envrc(path) called:
1. If path == _ENVRC_NESTED_UNLOADED → skip (prevents reload loop)
2. If ENVRC is set OR _ENVRC_NESTING_LEVEL == 0:
→ spawn subshell (clear ENVRC_*, increment nesting level, exec $SHELL)
→ after subshell exits: set _ENVRC_NESTED_UNLOADED, restore PWD
3. elif ENVRC is empty (and nesting level > 0):
→ source the .envrc directly, set ENVRC_* state vars
The key insight: branch 2 fires at nesting level 0, so even the first .envrc gets a subshell. Branch 3 (source directly) only runs inside that subshell, at level >= 1. This ensures every .envrc environment is isolated — the user's login shell is never polluted.
cd ~/project (has .envrc)
│
▼
precmd hook fires → _envrc_run_check()
│
▼
_find_up(".envrc") walks up from $PWD to /
│
▼
Found ~/project/.envrc, differs from current $ENVRC
│
▼
_load_envrc("~/project/.envrc")
│
├─ Level 0, ENVRC empty → branch 2: spawn subshell
│ │
│ ▼ (new shell, level=1, ENVRC="")
│ precmd fires again → _find_up finds same .envrc
│ │
│ ▼
│ _load_envrc → branch 3: source .envrc directly
│ Set ENVRC, ENVRC_DIR, ENVRC_NAME, ENVRC_PROMPT, _ENVRC_OWNER_PID
│
└─ Level >0, ENVRC set (nested) → branch 2: spawn another subshell
cd /somewhere/outside
│
▼
precmd hook fires → _envrc_run_check()
│
▼
$PWD is not under $ENVRC_DIR
│
▼
Write $PWD to the parent's PWD file (_ENVRC_PARENT_LAST_PWD_FILE)
exit (subshell terminates)
│
▼
Parent shell resumes after the `$SHELL` line in branch 2
│
▼
Read temp file, cd to last PWD if changed
_envrc_run_check() runs again (may load a different .envrc)
When a subshell exits, the parent shell's _find_up may rediscover the parent's own .envrc. Without a guard, this would spawn a subshell endlessly. _ENVRC_NESTED_UNLOADED stores the path of the .envrc that was just exited. It's checked in branch 1 of _load_envrc and cleared on the next chpwd.
Note: when the parent restores $PWD into a subdirectory of the just-exited tree, the restore cd itself fires chpwd, which would clear the guard before _envrc_run_check runs and re-spawn the subshell. _envrc_spawn_subshell therefore re-asserts _ENVRC_NESTED_UNLOADED immediately after the restore cd.
| Hook | Handler | Purpose |
|---|---|---|
precmd |
_envrc_run_check() |
Main trigger — runs before every prompt. Detects .envrc, and exits the subshell when cwd is outside ENVRC_DIR — but only in the shell that actually sourced it (_ENVRC_OWNER_PID == $$), so manually-nested shells aren't killed. |
chpwd |
_envrc_on_cd() |
Clears _ENVRC_NESTED_UNLOADED so the next .envrc can load. |
zshexit |
_envrc_on_shell_exit() |
On natural termination (Ctrl-D, exit): records $PWD to the parent's PWD file (only when this shell owns the env, _ENVRC_OWNER_PID == $$) so the parent restores the directory, and removes this shell's own IPC temp file. |
| Variable | Purpose |
|---|---|
ENVRC |
Full path to current .envrc file |
ENVRC_DIR |
Directory containing .envrc |
ENVRC_NAME |
Basename of directory (or ~ for home) |
ENVRC_PROMPT |
Prompt indicator, e.g. 📜.envrc[project-name] |
_ENVRC_NESTING_LEVEL |
Subshell depth (0 = login shell) |
_ENVRC_NESTED_UNLOADED |
Path of .envrc just exited — reload guard (local, not exported) |
_ENVRC_OWNER_PID |
$$ of the shell that sourced the .envrc. Only this shell auto-exits when cwd leaves ENVRC_DIR; nested shells that merely inherit the env do not. |
IPC between subshell and parent uses a per-process temp file, /tmp/envrc-subshell-last-pwd-$$ (scoped to the parent's PID). Each level passes its own file down to the child via _ENVRC_PARENT_LAST_PWD_FILE, so concurrent terminal sessions don't share state.
.envrc files are sourced as shell scripts. They have access to:
use TOOL [VERSION]— install and activate a tool version via asdf. VERSION defaults tolatest, which is resolved to a concrete version viaasdf latest. Installs (and reshims) the version if missing, then activates it for the current subshell by exportingASDF_<TOOL>_VERSION(e.g.ASDF_NODEJS_VERSION=22.5.1). Returns non-zero if asdf is missing or installation fails.PATH_add DIR— prepend a directory to$PATH._info MSG,_debug MSG,_success MSG,_error MSG— logging (controlled byENVRC_VERBOSE).
$ENVRC_DIR— the directory containing the.envrcbeing loaded. Useful for building absolute paths:PATH_add "${ENVRC_DIR}/node_modules/.bin" source $ENVRC_DIR/.venv/bin/activate
# Tool versions (via asdf)
use golang 1.23.2
use python 3.12.2
use nodejs 22.5.1
# Python virtualenv
source .venv/bin/activate
# uv-managed project
if [ ! -d $ENVRC_DIR/.venv/bin ]; then uv venv; fi
source .venv/bin/activate
uv sync
# PATH manipulation
PATH_add "${ENVRC_DIR}/node_modules/.bin"
PATH_add target/release
# Environment variables
export GOPATH=$PWD
export DEV_PROJECT=" configs"
export PYTHONBREAKPOINT=ipdb.set_traceSet ENVRC_VERBOSE before sourcing envrc.sh:
| Level | Output |
|---|---|
| 0 (default) | Errors only |
| 1 | + success messages |
| 2 | + info messages |
| 3 | + debug messages |
Logging functions are redefined to no-ops at init time — zero overhead for disabled levels.
ENVRC_PROMPT (e.g. 📜.envrc[project]) is exported when an .envrc is loaded, but envrc-tools does not modify your prompt automatically — doing so would clobber custom themes (oh-my-zsh, powerlevel10k, etc.). To surface it, reference $ENVRC_PROMPT in your own prompt with prompt_subst enabled:
setopt prompt_subst
PROMPT='${ENVRC_PROMPT:+$ENVRC_PROMPT }'"$PROMPT"- zsh only: subshells exec
$SHELL; if that isn't zsh, the hook can't re-register and.envrcfiles never load._envrc_hookwarns when$SHELLis not zsh. - Prompt-bound, not command-bound: load/unload happen in the
precmdhook (before the next prompt), not oncditself. So incd project && make,makeruns in the parent shell before the.envrcsubshell spawns; andcd .. && makeruns once with the still-loaded env before the auto-exit on the following prompt. This is inherent to the subshell model. Run the command on its own line (or start a fresh prompt) to get the loaded/unloaded environment. - No change detection: editing a loaded
.envrchas no effect until you leave and re-enter the directory (the same path short-circuits as "already loaded"). - No security allowlist: any
.envrcfile is sourced automatically. Nodirenv allow-style trust mechanism. - One
.envrcper scope: loads the nearest.envrcwalking upward. Does not merge or cascade multiple.envrcfiles. - Subshell cost: each active
.envrcis a shell process. Deep nesting means a stack of shell processes.