Skip to content

Latest commit

 

History

History
196 lines (147 loc) · 8.37 KB

File metadata and controls

196 lines (147 loc) · 8.37 KB

SPEC.md — envrc-tools

Concept

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.

Setup

In .zshrc:

source ~/path/to/envrc.sh; envrc hook zsh

This sources all functions into the current shell and registers zsh hooks (precmd, chpwd).

Active implementation

Only envrc.sh is in use. It is self-contained — no other files are sourced or referenced at runtime.

How it works

The _load_envrc branching logic

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.

Lifecycle: entering a directory

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

Lifecycle: leaving a directory

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)

The _ENVRC_NESTED_UNLOADED guard

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.

Shell hooks

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.

State variables

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.

API available inside .envrc files

.envrc files are sourced as shell scripts. They have access to:

Functions

  • use TOOL [VERSION] — install and activate a tool version via asdf. VERSION defaults to latest, which is resolved to a concrete version via asdf latest. Installs (and reshims) the version if missing, then activates it for the current subshell by exporting ASDF_<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 by ENVRC_VERBOSE).

Variables available to .envrc

  • $ENVRC_DIR — the directory containing the .envrc being loaded. Useful for building absolute paths:
    PATH_add "${ENVRC_DIR}/node_modules/.bin"
    source $ENVRC_DIR/.venv/bin/activate

Common .envrc patterns

# 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_trace

Verbosity

Set 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.

Showing the prompt indicator

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"

Known limitations

  • zsh only: subshells exec $SHELL; if that isn't zsh, the hook can't re-register and .envrc files never load. _envrc_hook warns when $SHELL is not zsh.
  • Prompt-bound, not command-bound: load/unload happen in the precmd hook (before the next prompt), not on cd itself. So in cd project && make, make runs in the parent shell before the .envrc subshell spawns; and cd .. && make runs 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 .envrc has no effect until you leave and re-enter the directory (the same path short-circuits as "already loaded").
  • No security allowlist: any .envrc file is sourced automatically. No direnv allow-style trust mechanism.
  • One .envrc per scope: loads the nearest .envrc walking upward. Does not merge or cascade multiple .envrc files.
  • Subshell cost: each active .envrc is a shell process. Deep nesting means a stack of shell processes.