Skip to content

Instantly share code, notes, and snippets.

@krzyzanowskim
Last active June 1, 2026 14:50
Show Gist options
  • Select an option

  • Save krzyzanowskim/07450322713433af08798a6ab0c0ce8f to your computer and use it in GitHub Desktop.

Select an option

Save krzyzanowskim/07450322713433af08798a6ab0c0ce8f to your computer and use it in GitHub Desktop.
Load .env files from $HOME down to the current directory.
# load-dotenv-up.zsh
#
# Load .env files from $HOME down to the current directory.
# Deeper .env files override parent .env files.
# On directory change, variables touched by the previous .env chain are restored
# to their original values or unset if they did not exist before.
#
# NOTE: .env files must be valid zsh syntax.
# NOTE: Regular files and FIFO/named-pipe .env files are loaded.
# FIFO .env files (e.g. fed by the 1Password CLI) are opened
# non-blocking and read with a timeout, so a pipe with no writer
# cannot hang the shell. Tune the wait with:
# export _DOTENV_UP_FIFO_TIMEOUT=5 # seconds (default 2)
#
# Installation:
# 1. Save this file as:
# ~/.zsh/plugins/load-dotenv-up.zsh
#
# 2. Add this line to ~/.zshrc:
# source ~/.zsh/plugins/load-dotenv-up.zsh
#
# 3. Reload zsh:
# source ~/.zshrc
autoload -Uz add-zsh-hook
typeset -gA _dotenv_up_prev_value
typeset -gA _dotenv_up_prev_was_set
typeset -ga _dotenv_up_touched
# Max seconds to wait on a FIFO .env before giving up (no writer / slow writer).
: ${_DOTENV_UP_FIFO_TIMEOUT:=2}
# Cache of contents drained from each FIFO .env, keyed by path. A pipe can only
# be read once (the writer feeds it a single time), so we replay the cached
# contents on later chpwd reloads instead of reopening the now-empty pipe.
typeset -gA _dotenv_up_fifo_cache
_dotenv_up_source_file() {
emulate -L zsh
local envfile="$1"
setopt allexport
if [[ -p "$envfile" ]]; then
if [[ -n "${_dotenv_up_fifo_cache[$envfile]+x}" ]]; then
# Already drained this pipe; replay cached contents.
eval "${_dotenv_up_fifo_cache[$envfile]}"
else
# FIFO: open non-blocking so the open() can't hang waiting for a writer,
# then drain with a per-read timeout. A pipe with no writer returns EOF
# immediately and is treated as empty.
zmodload zsh/system 2>/dev/null
local fd chunk data=""
if sysopen -r -o nonblock -u fd -- "$envfile" 2>/dev/null; then
while sysread -t "$_DOTENV_UP_FIFO_TIMEOUT" -i $fd chunk 2>/dev/null; do
data+="$chunk"
done
exec {fd}<&-
fi
# Only cache once we actually received something, so an empty read
# (writer not ready yet) can still be retried on a later reload.
[[ -n "$data" ]] && _dotenv_up_fifo_cache[$envfile]="$data"
eval "$data"
fi
else
source "$envfile"
fi
unsetopt allexport
}
_dotenv_up_snapshot_env() {
emulate -L zsh
local target="$1"
local name value
case "$target" in
before) before=() ;;
after) after=() ;;
*) return 1 ;;
esac
while IFS='=' read -r name value; do
# Environment names should be shell identifiers. Skip unusual names to avoid
# treating them as arithmetic expressions in associative-array subscripts.
[[ "$name" == [A-Za-z_][A-Za-z0-9_]* ]] || continue
case "$target" in
before) before[$name]="$value" ;;
after) after[$name]="$value" ;;
esac
done < <(env)
}
_dotenv_up_restore_previous() {
emulate -L zsh
local name
for name in "${_dotenv_up_touched[@]}"; do
if [[ "${_dotenv_up_prev_was_set[$name]}" == "1" ]]; then
export "$name=${_dotenv_up_prev_value[$name]}"
else
unset "$name"
fi
done
_dotenv_up_touched=()
_dotenv_up_prev_value=()
_dotenv_up_prev_was_set=()
}
_dotenv_up_load() {
emulate -L zsh
_dotenv_up_restore_previous
local cwd="${PWD:A}"
local home="${HOME:A}"
# Only load .env files when current directory is inside $HOME.
[[ "$cwd" == "$home" || "$cwd" == "$home"/* ]] || return 0
local -A before after
_dotenv_up_snapshot_env before
local dir="$cwd"
local -a dirs
dirs=()
while true; do
dirs=("$dir" "${dirs[@]}")
[[ "$dir" == "$home" ]] && break
dir="${dir:h}"
done
local envfile
for dir in "${dirs[@]}"; do
envfile="$dir/.env"
if [[ -f "$envfile" || -p "$envfile" ]]; then
_dotenv_up_source_file "$envfile"
fi
done
_dotenv_up_snapshot_env after
local name
# Variables added or changed by .env files.
for name in "${(@k)after}"; do
if [[ -z "${before[$name]+x}" || "${before[$name]}" != "${after[$name]}" ]]; then
_dotenv_up_touched+=("$name")
if [[ -n "${before[$name]+x}" ]]; then
_dotenv_up_prev_was_set[$name]=1
_dotenv_up_prev_value[$name]="${before[$name]}"
else
_dotenv_up_prev_was_set[$name]=0
fi
fi
done
# Variables removed by .env files.
for name in "${(@k)before}"; do
if [[ -z "${after[$name]+x}" ]]; then
_dotenv_up_touched+=("$name")
_dotenv_up_prev_was_set[$name]=1
_dotenv_up_prev_value[$name]="${before[$name]}"
fi
done
}
_dotenv_up_load
add-zsh-hook chpwd _dotenv_up_load
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment