#!/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" # # LOGGING # # Usage: __ren_log "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 ".#__std.cellsFrom" 2>/dev/null || true) if [[ -z "$cellsFrom" ]]; then __ren_log TRACE "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}/direnv" # shellcheck disable=SC2034 direnv_layout_dir="${REN_STATE}/direnv" # add nested gitignore which ignores the whole state dir echo "**/*" >"$REN_STATE/.gitignore" } # # 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"* # 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 } # # 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 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 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" __ren_update_gcroots "$__layout_dir" "$tmp_profile" "$profile" "$cell" __ren_log TRACE "Cache for '$target_spec' renewed successfully." return 0 else rm -f "$tmp_profile"* return 1 fi } # # ENTRYPOINTS # # usage in .envrc: use_envreload # can be `//cell/block/target` or a direct flake attr like `.#myShell` use_envreload() { 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_envreload requires a target argument, e.g., //repo/devShells/default or .#myShell" return 1 fi __ren_setup_watches "$@" 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" } # 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" }