#!/usr/bin/env -S -u BASH_ENV /bin/bash # shellcheck shell=bash # Contract test for .githooks/pre-commit. # # Run it DIRECTLY -- ./test/prek-hook-test -- never `bash test/prek-hook-test`. # The whole shebang is load-bearing, and for hygiene rather than for defence # against this repository, which supplies this file anyway. -u BASH_ENV: # non-interactive bash sources $BASH_ENV before line 1, so a value inherited # from an unrelated tool runs arbitrary code ahead of everything below. # /bin/bash rather than bash: the interpreter is chosen before line 1 too, so # an ambient PATH would otherwise decide which bash reads this. Either can # quietly change what this test reports, and neither can be checked from inside # the file -- so the "test harness interpreter" section re-runs line 1 against # both. # # Git runs that hook three ways: from a terminal, where a login profile has # already built PATH; from a GUI client (Tower, Fork, GitHub Desktop) with a # bare launchd/systemd environment and no profile; and from an agent session -- # Claude Code, Codex and opencode are provisioned fleet-wide -- whose PATH # orders mise's shims BELOW /usr/bin. All three must resolve this project's # mise-managed toolchain and export its [env]. # # That third one is why the hook declares the shims directory itself. `mise # exec` strips the install directories it recognises out of the inherited PATH, # then inserts the resolved ones immediately BEFORE the shims entry, keeping # that entry -- or prepends them when there is no shims entry at all. So shims # ordered below /usr/bin silently resolve /usr/bin/ruby, and a globally # activated installs/ruby/4/bin nine places AHEAD of /usr/bin loses to it too, # because mise strips that entry and re-lands the toolchain at the shims # position. Foreign entries are never touched. An earlier revision of # this file recorded the opposite -- that shipyard "already orders ~/.local/bin # and mise's shims above /usr/bin, so `mise exec` resolves the toolchain # unaided" -- and dropped the shims handling as unnecessary. A login shell does # order them that way; an agent session does not, and on that premise the hook # shipped resolving the wrong ruby. The third scenario below is what now holds # the premise to account. # # Run it directly: ./test/prek-hook-test # # Deliberately small, but not empty. A GUI launch runs no login profile, so # nothing orders coexisting mise installations for it -- the hook picks by # version floor instead, and refuses to let a prerelease satisfy the stable # floor it precedes. Both are exercised below. set -uo pipefail # Sanitize PATH first -- before `cd`, before `dirname`, before any external # command at all. This runs with a checkout as its cwd and shipyard puts ./bin # at high precedence, so whichever tree you are in supplies the dirname, sed and # mise this test depends on, and the result would quietly describe that tree # instead of this one. Builtins only: a filter built from external tools would # already be too late. _inherited_path=$PATH # before sanitizing; the terminal fallback needs the real one _absolute='' IFS=: for _dir in $PATH; do case $_dir in /*) _absolute="${_absolute:+$_absolute:}$_dir" ;; esac done unset IFS _dir PATH="${_absolute:+$_absolute:}$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" export PATH unset _absolute # Line 1 is re-asserted below, and `cd` invalidates a relative $0 -- read it # now, with a builtin, before either happens. Pre-set it so an unreadable $0 # fails that assertion under `set -u` rather than aborting the whole run. self_shebang='' IFS= read -r self_shebang < "$0" cd "$(dirname "$0")/.." || exit 1 HOOK=.githooks/pre-commit # shellcheck disable=SC2016 # the literal text of the hook's final line LAST_LINE='exec "$mise" exec -- prek run' pass=0 fail=0 ok() { pass=$((pass + 1)); printf ' ok %s\n' "$1"; } no() { fail=$((fail + 1)); printf ' NOT OK %s\n' "$1"; shift; [ $# -gt 0 ] && printf ' %s\n' "$@"; } die() { printf 'FATAL: %s\n' "$1" >&2; exit 1; } command -v mise >/dev/null || die "mise is not installed" # Ground truth from the config file, not from the dispatch under test. An empty # result is fatal rather than a silent skip, which would turn a broken # extraction into a false pass. env_section=$(sed -n '/^\[env\]/,/^\[/p' .mise.toml) || die "could not read .mise.toml" env_keys=$(printf '%s\n' "$env_section" | grep -E '^[A-Za-z_][A-Za-z0-9_]*[[:space:]]*=' | sed 's/[[:space:]]*=.*//') [ -n "$env_keys" ] || die "no [env] keys found in .mise.toml" [ "$(tail -n 1 "$HOOK")" = "$LAST_LINE" ] || die "hook's last line is not: $LAST_LINE" workdir=$(mktemp -d "${TMPDIR:-/tmp}/prek-hook-test.XXXXXX") || die "mktemp failed" relbin=$(mktemp -d ./prek-hook-test.XXXXXX) || die "mktemp failed" trap 'rm -rf "$workdir" "$relbin"' EXIT # --- this harness's own interpreter ------------------------------------------ # Everything below trusts that line 1 named the interpreter absolutely and # dropped BASH_ENV. Both take effect before any line of this file runs, so # neither can be observed from the inside -- instead run a probe carrying this # file's shebang verbatim, under the two environments it defends against. echo "test harness interpreter:" if [ "$self_shebang" = '#!/usr/bin/env -S -u BASH_ENV /bin/bash' ]; then ok "shebang names /bin/bash absolutely and unsets BASH_ENV" else no "shebang names /bin/bash absolutely and unsets BASH_ENV" "line 1: ${self_shebang:-}" fi hostile="$workdir/hostile" mkdir -p "$hostile" printf '#!/bin/sh\necho HOSTILE_BASH_RAN\nexit 0\n' > "$hostile/bash" chmod +x "$hostile/bash" printf 'echo POISONED_BASH_ENV_RAN\n' > "$workdir/bash_env" printf '%s\necho PROBE_RAN\n' "$self_shebang" > "$workdir/shebang-probe" chmod +x "$workdir/shebang-probe" shebang_out=$(env -i HOME="$HOME" PATH="$hostile:/usr/bin:/bin" \ BASH_ENV="$workdir/bash_env" "$workdir/shebang-probe" 2>&1) # The probe has to reach its own body first; without that the two absences # below would pass for a probe that never ran at all. case $shebang_out in *PROBE_RAN*) ok "the probe runs under a hostile PATH and a poisoned BASH_ENV" ;; *) no "the probe runs under a hostile PATH and a poisoned BASH_ENV" "output: ${shebang_out:-}" ;; esac case $shebang_out in *HOSTILE_BASH_RAN*) no "a bash earlier on PATH is not the interpreter" "output: $shebang_out" ;; *) ok "a bash earlier on PATH is not the interpreter" ;; esac case $shebang_out in *POISONED_BASH_ENV_RAN*) no "an inherited BASH_ENV is not sourced" "output: $shebang_out" ;; *) ok "an inherited BASH_ENV is not sourced" ;; esac probe="$workdir/probe" cat > "$probe" <<'PROBE' #!/bin/sh printf 'ruby=%s\n' "$(ruby -e 'print RUBY_VERSION' 2>/dev/null || echo NONE)" printf 'ruby_path=%s\n' "$(command -v ruby || echo NONE)" printf 'MISE_PICK=%s\n' "${MISE_PICK-}" for k in $PROBE_ENV_KEYS; do eval "printf '%s=%s\n' \"\$k\" \"\${$k-}\"" done PROBE chmod +x "$probe" # Swap only the hook's final exec for the probe, so its PATH handling runs # verbatim. hook_with_probe() { local tmp="$workdir/hook.$1" sed "s|^$(printf '%s' "$LAST_LINE" | sed 's/[[\.*^$/]/\\&/g')\$|exec \"\$mise\" exec -- $probe|" "$HOOK" > "$tmp" chmod +x "$tmp" printf '%s' "$tmp" } field() { printf '%s\n' "$1" | sed -n "s/^$2=//p"; } # Extra KEY=VALUE arguments after the hook are added to the modelled # environment. Used to give the terminal scenario the store configuration a # login profile supplies; the GUI scenario deliberately gets none. run_hook() { local path=$1 hook=$2 shift 2 env -i HOME="$HOME" USER="${USER:-$(id -un)}" LOGNAME="${USER:-$(id -un)}" \ SHELL=/bin/sh TMPDIR="${TMPDIR:-/tmp}" PROBE_ENV_KEYS="$env_keys" \ ${1+"$@"} PATH="$path" "$hook" 2>&1 } # The agent scenario inherits the invoking environment rather than rebuilding it. # An agent session does inherit the developer's environment, so this models one # directly -- and it is why no store variables are forwarded: expected_ruby_path # is derived in this same environment, so the two agree by construction, under # any store configuration. The forwarded list this replaces had to name every # variable that can relocate mise's install tree, and twice did not. # # It could not have been completed even in principle, which is the real argument # for deleting it rather than extending it a third time. shared_install_dirs is # a genuine mise SETTING, so a config FILE relocates the install tree just as an # environment variable does -- and the list named no config-dir variable at all. # Measured: with MISE_CONFIG_DIR pointing at a config declaring # shared_install_dirs, the old harness failed a working hook and this one # passes. (data_dir, shims_dir and installs_dir are environment-only by # contrast: `mise settings get` calls them "Unknown setting", not merely unset, # and a config file setting them is ignored with a warning.) # # Three overrides and two removals. PATH is the thing under test. # __MISE_ORIG_PATH is what a real agent session carries, and must describe the # PATH we are supplying, not the one we inherited -- so __MISE_DIFF, which # describes the inherited PATH, is dropped as incoherent with it. # # The [env] keys are unset because this environment may already carry them: a # mise-activated project shell exports PROMETHEUS_EXPORTER_URL, and running the # suite that way is normal. Inheriting them would let the [env] assertions pass # on ambient state instead of on what the hook exported -- decorative exactly # the way two assertions in this file already once shipped decorative. The list # is $env_keys, read from .mise.toml, so it carries no completeness obligation. # # Accepted consequence: a genuinely broken developer environment (say # MISE_AUTO_INSTALL=0) now surfaces here. That is fidelity, not flakiness -- # their actual hook would fail the same way. # The third argument picks how much of mise's activation state to carry: # # keep drop __MISE_DIFF only. The faithful model -- a real agent session # inherits a shell that mise activated, so it does carry the rest. # bare also drop __MISE_SESSION, MISE_SHELL and __MISE_EXE. # # Both are exercised, because neither is obviously the right one. `keep` leaves a # combination no real shell produces (activated, but with the PATH delta removed) # out of undocumented mise internals; `bare` is coherent but drifts from the # session being modelled. Rather than pick one interpretation and hope, assert # the hook resolves correctly under both -- which is the property actually # wanted, and which stays checked if a future mise changes what these mean. run_hook_inheriting() { local path=$1 hook=$2 activation=${3:-keep} k local scrub=() for k in $env_keys; do scrub+=(-u "$k"); done case $activation in keep) ;; bare) scrub+=(-u __MISE_SESSION -u MISE_SHELL -u __MISE_EXE) ;; *) die "run_hook_inheriting: unknown activation mode '$activation'" ;; esac # ${scrub[@]+...} for the same bash 3.2 reason as $login_store below, even # though $env_keys is fatal-if-empty above and so this array never is. env ${scrub[@]+"${scrub[@]}"} -u __MISE_DIFF \ PATH="$path" __MISE_ORIG_PATH="$path" PROBE_ENV_KEYS="$env_keys" \ "$hook" 2>&1 } # Ground truth, and it cannot be poisoned by the ordering these scenarios test, # which is worth saying because it looks like it could be: this runs in the # invoking environment, and that may itself be an agent shell with shims below # /usr/bin. The distinction is what mise is asked to resolve. `mise exec -- ruby` # and `mise which ruby` name a TOOL, which mise resolves from its own registry; # both return the project's ruby even with a decoy ruby ahead of the shims entry # on PATH. `mise exec --