direnv/direnvrc

503 lines
14 KiB
Bash

#!/usr/bin/env bash
#
# A direnv hook for loading Nix flake-based development environments
# with built-in caching and garbage-collection root management.
#
# Based on
# https://github.com/nix-community/nix-direnv
# and
# https://github.com/paisano-nix/direnv
#
set -euo pipefail
readonly RENSA_DIRENV_VERSION="0.1.0"
readonly DIRENV_MIN_VERSION="2.21.3"
readonly BASH_MIN_VERSION="4.4"
#
# CONFIGURATION ENV VARS
#
REN_FLAKE_ATTR=${REN_FLAKE_ATTR-"__ren"}
# whether to enable watching the files for changes
REN_DO_WATCH=${REN_DO_WATCH-true}
# whether to add the shell profile or flake inputs to gcroots
REN_DO_GCROOTS=${REN_DO_GCROOTS-true}
# whether to archive the flake inputs and gcroot them (only works if REN_DO_GCROOT is true)
REN_DO_ARCHIVE=${REN_DO_ARCHIVE-true}
# whether to enable compat mode which sets PRJ env variables
REN_DO_PRJ_COMPAT=${REN_DO_PRJ_COMAT-true}
#
# LOGGING
#
# Usage: __ren_log <LEVEL> "message"
# LEVEL can be: TRACE, INFO, WARN, ERROR
__ren_log() {
local level="$1" msg="$2"
local format=$'\E[mren: \E[38;5;8m%s\E[m'
case "$level" in
TRACE)
if [[ -n "${REN_TRACE:-}" ]]; then
# shellcheck disable=SC2059
log_status "$(printf "$format" "[TRACE] ${msg}")"
fi
;;
INFO)
# shellcheck disable=SC2059
log_status "$(printf "$format" "${msg}")"
;;
WARN)
# shellcheck disable=SC2059
log_error "$(printf "$format" "[WARN] ${msg}")"
;;
ERROR)
# shellcheck disable=SC2059
log_error "$(printf "$format" "[ERROR] ${msg}")"
;;
esac
}
#
# UTILS
#
__ren_require_version() {
local name="$1" version="$2" required="$3"
if ! printf "%s\n%s\n" "$required" "$version" | LC_ALL=C sort --check --version-sort &>/dev/null; then
__ren_log ERROR "Minimum required ${name} version is ${required} (found: ${version})."
return 1
fi
}
__ren_require_cmd_version() {
local cmd="$1" required="$2" version
if ! has "$cmd"; then
__ren_log ERROR "Command not found: $cmd"
return 1
fi
version=$($cmd --version)
if [[ $version =~ ([0-9]+\.[0-9]+\.[0-9]+) ]]; then
__ren_require_version "$cmd" "${BASH_REMATCH[1]}" "$required"
else
__ren_log WARN "Could not parse version for '$cmd'. Skipping version check."
fi
}
__cell_path_cache=""
__ren_get_cell_path() {
if [[ -z "$__cell_path_cache" ]]; then
local cell="$1"
local cellsFrom
cellsFrom=$(__ren_nix eval --raw ".#${REN_FLAKE_ATTR}.cellsFrom" 2>/dev/null || true)
if [[ -z "$cellsFrom" ]]; then
__ren_log ERROR "Could not determine 'cellsFrom' path from flake."
return 1
fi
__cell_path_cache=$cellsFrom
fi
echo "${REN_ROOT}/${__cell_path_cache}/${cell}"
}
# set in the entrypoint
__nix_cmd=""
__ren_nix() {
"$__nix_cmd" --no-warn-dirty --extra-experimental-features "nix-command flakes" "$@"
}
#
# INIT
#
# discovers the project root (git repo) and sets up state directories and variables
__ren_init_project() {
[[ -n "${REN_ROOT:-}" ]] && return 0
local git_repo
git_repo=$(git rev-parse --show-toplevel 2>/dev/null || true)
if [[ -z "$git_repo" ]]; then
__ren_log ERROR "Not inside a git repository. Cannot determine project root."
return 1
fi
export REN_ROOT="$git_repo"
export REN_STATE="${REN_ROOT}/.ren"
mkdir -p "${REN_STATE}"
# shellcheck disable=SC2034
direnv_layout_dir="${REN_STATE}/direnv"
mkdir -p "${REN_STATE}/direnv"
# add nested gitignore which ignores the whole state dir
echo "**/*" >"$REN_STATE/.gitignore"
if [ "$REN_DO_PRJ_COMPAT" = true ]; then
__ren_log INFO "Setting PRJ env variables for compat"
__ren_init_prj_spec
else
__ren_log TRACE "Not setting PRJ env variables"
fi
}
# compat with other stuff that uses PRJ_...
# we explicitly don't want any dirs to be in the project dir, put them all
# in .ren, so they don't clutter everything up
__ren_init_prj_spec() {
export PRJ_ROOT=${PRJ_ROOT:="${REN_ROOT}"}
export PRJ_CONFIG_HOME=${PRJ_CONFIG_HOME:="${REN_STATE}/config"}
mkdir -p "${PRJ_CONFIG_HOME}"
export PRJ_RUNTIME_DIR=${PRJ_RUNTIME_DIR:="${REN_STATE}/runtime"}
mkdir -p "${PRJ_RUNTIME_DIR}"
# load project id if exists
if [[ -z "${PRJ_ID:-}" && -f "${PRJ_CONFIG_HOME}/prj_id" ]]; then
export PRJ_ID=$(<"${PRJ_CONFIG_HOME}/prj_id")
fi
# PRJ_CACHE_HOME - shared if PRJ_ID is set
if [[ -z "${PRJ_CACHE_HOME:-}" ]]; then
if [[ -n "${PRJ_ID:-}" ]]; then
export PRJ_CACHE_HOME="${XDG_CACHE_HOME}/prj/${PRJ_ID}"
else
export PRJ_CACHE_HOME="${REN_STATE}/cache"
fi
fi
mkdir -p "${PRJ_CACHE_HOME}"
# PRJ_DATA_HOME - shared if PRJ_ID is set
if [[ -z "${PRJ_DATA_HOME:-}" ]]; then
if [[ -n "${PRJ_ID:-}" ]]; then
export PRJ_DATA_HOME="${XDG_DATA_HOME}/prj/${PRJ_ID}"
else
export PRJ_DATA_HOME="${REN_STATE}/data"
fi
fi
# NOTE: stuff like devshell uses PRJ_DATA_DIR instead, even though numtide
# wrote the spec? :D
# technically not supported so we just overwrite it, use PRJ_DATA_HOME instead
export PRJ_DATA_DIR=$PRJ_DATA_HOME
mkdir -p "${PRJ_DATA_HOME}"
# PRJ_PATH - shared if PRJ_ID is set
if [[ -z "${PRJ_PATH:-}" ]]; then
if [[ -n "${PRJ_ID:-}" ]]; then
export PRJ_PATH="${HOME}/.local/bin/prj/${PRJ_ID}"
else
export PRJ_PATH="${REN_STATE}/bin"
fi
fi
mkdir -p "${PRJ_PATH}"
}
#
# NIX GCROOTS & ENV STUFF
#
__ren_import_env() {
local profile_rc="$1"
[[ -f "$profile_rc" ]] || return 0
local -A to_restore
to_restore=(
["NIX_BUILD_TOP"]="${NIX_BUILD_TOP:-__UNSET__}"
["TMP"]="${TMP:-__UNSET__}"
["TMPDIR"]="${TMPDIR:-__UNSET__}"
["TEMP"]="${TEMP:-__UNSET__}"
["TEMPDIR"]="${TEMPDIR:-__UNSET__}"
["terminfo"]="${terminfo:-__UNSET__}"
)
local old_xdg_data_dirs="${XDG_DATA_DIRS:-}"
# shellcheck source=/dev/null
eval "$(<"$profile_rc")"
if [[ -n "${NIX_BUILD_TOP+x}" && $NIX_BUILD_TOP == */nix-shell.* && -d $NIX_BUILD_TOP ]]; then rm -rf "$NIX_BUILD_TOP"; fi
for key in "${!to_restore[@]}"; do
if [[ "${to_restore[$key]}" == "__UNSET__" ]]; then unset "$key"; else export "$key=${to_restore[$key]}"; fi
done
local new_xdg_data_dirs="${XDG_DATA_DIRS:-}"
export XDG_DATA_DIRS=""
local IFS=:
for dir in $new_xdg_data_dirs${old_xdg_data_dirs:+:}$old_xdg_data_dirs; do
dir="${dir%/}"
[[ -z "$dir" || ":$XDG_DATA_DIRS:" == *:"$dir":* ]] && continue
XDG_DATA_DIRS="${XDG_DATA_DIRS}${XDG_DATA_DIRS:+:}$dir"
done
}
__ren_add_gcroot() {
local storepath="$1" symlink="$2"
__ren_log TRACE "Adding GC root: $symlink -> $storepath"
__ren_nix build --out-link "$symlink" "$storepath" >/dev/null
}
__ren_clean_old_gcroots() {
__ren_log TRACE "Cleaning old GC roots in $1"
rm -rf "${1}/flake-inputs/" "${1}/profile-"*
}
__ren_argsum_suffix() {
local out checksum
if [[ -n "$1" ]]; then
if has sha1sum; then
out=$(sha1sum <<<"$1")
elif has shasum; then
out=$(shasum <<<"$1")
else
__ren_log WARN "sha1sum/shasum not found; cannot create stable profile name."
return
fi
read -r checksum _ <<<"$out"
echo "-$checksum"
fi
}
# archive flake inputs and gcroot them
__ren_add_flake_input_gcroots() {
local flake_dir="$1" __layout_dir="$2"
__ren_log TRACE "Archiving flake inputs from '$flake_dir'"
local flake_inputs_dir="${__layout_dir}/flake-inputs/"
mkdir -p "$flake_inputs_dir"
local flake_inputs_json
# run in subshell to not affect the current directory and handle errors gracefully
flake_inputs_json=$( (cd "$flake_dir" && __ren_nix flake archive --json --no-write-lock-file -- ".") 2>/dev/null || true)
if [[ -z "$flake_inputs_json" ]]; then
__ren_log TRACE "No inputs found or error archiving flake at '$flake_dir'. Skipping."
return
fi
while [[ $flake_inputs_json =~ /nix/store/[^\"]+ ]]; do
local store_path="${BASH_REMATCH[0]}"
[[ -z "$store_path" ]] && continue
__ren_add_gcroot "$store_path" "${flake_inputs_dir}/${store_path##*/}"
flake_inputs_json="${flake_inputs_json/${store_path}/}"
done
}
# updates gcroots
__ren_update_gcroots() {
local __layout_dir="$1" tmp_profile="$2" profile="$3" cell="$4"
__ren_add_gcroot "$tmp_profile" "$profile"
rm -f "$tmp_profile"*
if [ "$REN_DO_ARCHIVE" = true ]; then
# add gcroots for the main flake
__ren_add_flake_input_gcroots "$REN_ROOT" "$__layout_dir"
# if a cell is specified, also gcroot it's flake inputs, if it has a flake
if [[ -n "$cell" ]]; then
local cell_path
if cell_path=$(__ren_get_cell_path "$cell"); then
if [[ -f "${cell_path}/flake.nix" ]]; then
__ren_log TRACE "Found cell flake. Adding GC roots for inputs from '${cell_path}'."
__ren_add_flake_input_gcroots "$cell_path" "$__layout_dir"
fi
fi
fi
fi
}
#
# WATCHERS
#
__ren_get_direnv_watches() {
local -n _watches_ref=$1
[[ -z "${DIRENV_WATCHES-}" ]] && return 0
# shellcheck disable=SC2154
while IFS= read -r line; do
local regex='"[Pp]ath": "(.+)"$'
if [[ $line =~ $regex ]]; then
local path
# shellcheck disable=SC2059
path=$(printf "${BASH_REMATCH[1]}")
if [[ $path != "${XDG_DATA_HOME:-${HOME:-/var/empty}/.local/share}/direnv/allow/"* ]]; then
_watches_ref+=("$path")
fi
fi
done < <("$direnv" show_dump "${DIRENV_WATCHES}")
}
__ren_watch_cell() {
local target_spec="$1"
read -r cell block _ <<<"${target_spec//\// }"
local cell_path
if ! cell_path=$(__ren_get_cell_path "$cell"); then
__ren_log WARN "Could not determine 'cellsFrom' path from flake. File watching for target files will be incomplete."
return
fi
if [[ -f "${cell_path}/${block}.nix" ]]; then
__ren_log TRACE "Watching: ${cell_path}/${block}.nix"
watch_file "${cell_path}/${block}.nix"
fi
if [[ -f "${cell_path}/default.nix" ]]; then
__ren_log TRACE "Watching: ${cell_path}/default.nix"
watch_file "${cell_path}/default.nix"
fi
if [[ -d "${cell_path}/${block}" ]]; then
__ren_log TRACE "Watching dir: ${cell_path}/${block}"
watch_dir "${cell_path}/${block}"
fi
# watch for cell-level flake.nix and flake.lock
if [[ -f "${cell_path}/flake.nix" ]]; then
__ren_log TRACE "Watching cell flake: ${cell_path}/flake.nix"
watch_file "${cell_path}/flake.nix"
fi
if [[ -f "${cell_path}/flake.lock" ]]; then
__ren_log TRACE "Watching cell flake lock: ${cell_path}/flake.lock"
watch_file "${cell_path}/flake.lock"
fi
}
__ren_setup_watches() {
watch_file "$REN_ROOT/flake.nix" "$REN_ROOT/flake.lock"
for target in "$@"; do
if [[ "$target" != *"#"* ]]; then
__ren_watch_cell "$target"
fi
done
}
#
# BUILD & CACHE
#
# returns 1 if rebuild is needed
__ren_is_rebuild_needed() {
local profile_rc="$1"
if [[ ! -f "$profile_rc" ]]; then
__ren_log TRACE "No cached profile found at '$profile_rc'. Rebuilding."
echo 1
return
fi
if [ "$REN_DO_WATCH" = true ]; then
local watches=()
__ren_get_direnv_watches watches
for file in "${watches[@]}"; do
if [[ "$file" -nt "$profile_rc" ]]; then
__ren_log INFO "Cache invalidated by: $file"
echo 1
return
fi
done
fi
echo 0
}
# returns 0 on success, 1 on failure
__ren_build_and_cache() {
local target_spec="$1" __layout_dir="$2" profile="$3" profile_rc="$4"
__ren_log INFO "Building shell from '$target_spec'..."
local flake_attr cell=""
# allows specifying either flake attr like `./path#devShells.default` or through
# ren style `//repo/devShells/default`
if [[ "$target_spec" == *"#"* ]]; then
flake_attr="$target_spec"
__ren_log TRACE "Using direct flake attribute: $flake_attr"
else
read -r cell block target <<<"${target_spec//\// }"
local system
system=$(__ren_nix eval --raw --impure --expr builtins.currentSystem)
flake_attr=".#${system}.${cell}.${block}.${target}"
fi
local tmp_profile="${__layout_dir}/tmp-profile.$$"
if build_output=$(__ren_nix print-dev-env --profile "$tmp_profile" "$flake_attr"); then
__ren_clean_old_gcroots "$__layout_dir"
echo "$build_output" >"$profile_rc"
# only add new gcroots if enabled, cleaning old is always fine
if [ "$REN_DO_GCROOTS" = true ]; then
__ren_update_gcroots "$__layout_dir" "$tmp_profile" "$profile" "$cell"
fi
__ren_log TRACE "Cache for '$target_spec' renewed successfully."
return 0
else
rm -f "$tmp_profile"*
return 1
fi
}
#
# ENTRYPOINTS
#
# usage in .envrc: use ren <target>
# <target> can be `//cell/block/target` or a direct flake attr like `.#myShell`
use_ren() {
if [[ -z ${REN_SKIP_VERSION_CHECK:-} ]]; then
__ren_require_version "bash" "$BASH_VERSION" "$BASH_MIN_VERSION"
__ren_require_cmd_version "direnv" "$DIRENV_MIN_VERSION"
fi
if command -v nix &>/dev/null; then
__nix_cmd=$(command -v nix)
elif [[ -n "${REN_FALLBACK_NIX:-}" ]]; then
__nix_cmd="${REN_FALLBACK_NIX}"
else
__ren_log ERROR "Could not find Nix. Add 'nix' to PATH or set REN_FALLBACK_NIX."
return 1
fi
local target_spec="${1-}"
if [[ -z "$target_spec" ]]; then
__ren_log ERROR "use ren requires a target argument, e.g., //repo/devShells/default or .#myShell"
return 1
fi
if [ "$REN_DO_WATCH" = true ]; then
__ren_setup_watches "$@"
fi
local __layout_dir profile profile_rc
__layout_dir=$(direnv_layout_dir)
profile="${__layout_dir}/profile$(__ren_argsum_suffix "$target_spec")"
profile_rc="${profile}.rc"
if [[ $(__ren_is_rebuild_needed "$profile_rc") -eq 1 ]]; then
if ! __ren_build_and_cache "$target_spec" "$__layout_dir" "$profile" "$profile_rc"; then
if [[ -f "$profile_rc" ]]; then
__ren_log WARN "Nix evaluation failed. Falling back to the last known-good environment."
export REN_DID_FALLBACK=1
else
__ren_log ERROR "Nix evaluation failed, and no cached environment is available."
return 1
fi
fi
else
__ren_log INFO "Using cached environment for '$target_spec'."
fi
__ren_import_env "$profile_rc"
}
use_rensa() {
use_ren "$@"
}
# initialize project environment variables immediately upon sourcing the script,
# allows the user to use $REN_STATE etc. in .envrc
__ren_init_project
__ren_log TRACE "Rensa v${RENSA_DIRENV_VERSION}"
#
# HELPERS
# to use in .envrc, eg. to switch between shells etc.
#
# read state from the project
read_state() {
if [[ -f "$REN_STATE/$1" ]]; then
cat "$REN_STATE/$1"
fi
}
# set/write state for the project
write_state() {
echo "$2" >"$REN_STATE/$1"
}