Something went wrong. Try again.
A small, fast, drop-in replacement for git-hooks.nix
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577# Unit tests for lib/hooks-runtime.sh setup() { export NIXHOOKS_LIB="${NIXHOOKS_LIB:-$BATS_TEST_DIRNAME/../lib}" # shellcheck source=../lib/hooks-runtime.sh source "$NIXHOOKS_LIB/hooks-runtime.sh" TEST_REPO="$(mktemp -d)" cd "$TEST_REPO" || return 1 git init -q git config user.email test@example.com git config user.name test RECORD_FILE="$TEST_REPO/.record" RECORDER="$TEST_REPO/.recorder.sh" cat >"$RECORDER" <<'EOF'#!/bin/shprintf '%s\n' "$@" >"$RECORD_FILE"EOF chmod +x "$RECORDER" export RECORD_FILE}teardown() { cd / || return 1 rm -rf "$TEST_REPO"}# run_hook: file matching / exclude / always_run / pass_filenames ------@test "run_hook passes only files matching 'files' to the entry" { NIXHOOKS_FILES=("a.sh" "b.txt") run_hook myhook '\.sh$' '^$' 1 0 1 "" "$RECORDER" '' run cat "$RECORD_FILE" [[ "$output" == *"a.sh"* ]] [[ "$output" != *"b.txt"* ]]}@test "run_hook excludes files matching 'exclude'" { NIXHOOKS_FILES=("a.sh" "vendor/b.sh") run_hook myhook '\.sh$' '^vendor/' 1 0 1 "" "$RECORDER" '' run cat "$RECORD_FILE" [[ "$output" == *"a.sh"* ]] [[ "$output" != *"vendor/b.sh"* ]]}@test "run_hook does not invoke the entry when nothing matches" { NIXHOOKS_FILES=("a.txt") run_hook myhook '\.sh$' '^$' 1 0 1 "" "$RECORDER" '' [ ! -e "$RECORD_FILE" ]}@test "run_hook skips default files regex when there are no candidate files" { NIXHOOKS_FILES=() run run_hook myhook '.*' '^$' 1 0 1 "" "$RECORDER" '' [ "$status" -eq 0 ] [[ "$output" == *"nixhooks: skip myhook (no matching files)"* ]] [ ! -e "$RECORD_FILE" ]}@test "run_hook with always_run=1 invokes the entry with no files matched" { NIXHOOKS_FILES=() run_hook myhook '.*' '^$' 1 1 1 "" "$RECORDER" '' [ -e "$RECORD_FILE" ] # $(...) strips the recorder's trailing newline, so this is empty iff no # arguments were passed (printf '%s\n' with zero args still emits one blank line). [ -z "$(cat "$RECORD_FILE")" ]}@test "run_hook with pass_filenames=0 invokes the entry without filenames" { NIXHOOKS_FILES=("a.sh") run_hook myhook '\.sh$' '^$' 0 0 1 "" "$RECORDER" '' [ -e "$RECORD_FILE" ] [ -z "$(cat "$RECORD_FILE")" ]}@test "run_hook appends the collected commit-msg file as a trailing arg" { NIXHOOKS_FILES=("/tmp/some/COMMIT_EDITMSG") run_hook commitlint '.*' '^$' 1 0 1 "" "$RECORDER" '' --edit run cat "$RECORD_FILE" [[ "$output" == *"--edit"* ]] [[ "$output" == *"/tmp/some/COMMIT_EDITMSG"* ]]}# run_hook: path_prefix @test "run_hook prepends path_prefix to PATH before invoking the entry" { local extra_bin_dir="$TEST_REPO/extra_bin" mkdir -p "$extra_bin_dir" cat >"$extra_bin_dir/sibling-tool" <<'EOF'#!/bin/shexit 0EOF chmod +x "$extra_bin_dir/sibling-tool" local checker="$TEST_REPO/checker.sh" cat >"$checker" <<'EOF'#!/bin/shcommand -v sibling-toolEOF chmod +x "$checker" NIXHOOKS_FILES=() run run_hook myhook '.*' '^$' 1 1 1 "" "$checker" "$extra_bin_dir" [ "$status" -eq 0 ] [[ "$output" == *"$extra_bin_dir/sibling-tool"* ]]}@test "run_hook does not touch PATH when path_prefix is empty" { local checker="$TEST_REPO/checker.sh" cat >"$checker" <<'EOF'#!/bin/shcommand -v sibling-toolEOF chmod +x "$checker" NIXHOOKS_FAILED=0 NIXHOOKS_FILES=() run_hook myhook '.*' '^$' 1 1 1 "" "$checker" '' || true [ "$NIXHOOKS_FAILED" -eq 1 ]}# run_hook: SKIP and failure aggregation @test "run_hook honors SKIP and never invokes the entry" { _nixhooks_skip=("myhook") NIXHOOKS_FILES=("a.sh") run_hook myhook '\.sh$' '^$' 1 0 1 "" "$RECORDER" '' [ ! -e "$RECORD_FILE" ]}@test "run_hook sets NIXHOOKS_FAILED when the entry fails, without aborting" { NIXHOOKS_FAILED=0 NIXHOOKS_FILES=("a.sh") run_hook myhook '\.sh$' '^$' 1 0 1 "" false '' || true [ "$NIXHOOKS_FAILED" -eq 1 ]}@test "run_hook leaves NIXHOOKS_FAILED untouched when the entry succeeds" { NIXHOOKS_FAILED=0 NIXHOOKS_FILES=("a.sh") run_hook myhook '\.sh$' '^$' 1 0 1 "" true '' [ "$NIXHOOKS_FAILED" -eq 0 ]}# run_hook: own exit status @test "run_hook itself returns nonzero when the entry fails" { NIXHOOKS_FILES=("a.sh") run run_hook myhook '\.sh$' '^$' 1 0 1 "" false '' [ "$status" -eq 1 ]}@test "run_hook itself returns zero when the entry succeeds" { NIXHOOKS_FILES=("a.sh") run run_hook myhook '\.sh$' '^$' 1 0 1 "" true '' [ "$status" -eq 0 ]}# run_hook: fallback / required@test "run_hook falls back to PATH when entry is missing" { mkdir -p "$TEST_REPO/bin" cp "$RECORDER" "$TEST_REPO/bin/mytool" NIXHOOKS_FILES=("a.sh") PATH="$TEST_REPO/bin:$PATH" run run_hook myhook '\.sh$' '^$' 1 0 1 mytool /nonexistent/bin/mytool '' [ "$status" -eq 0 ] [[ "$output" == *"nixhooks: run myhook (PATH: $TEST_REPO/bin/mytool)"* ]] [ "$(cat "$RECORD_FILE")" = "a.sh" ]}@test "run_hook fails a required hook whose entry and fallback are missing" { NIXHOOKS_FAILED=0 NIXHOOKS_FILES=("a.sh") run_hook myhook '\.sh$' '^$' 1 0 1 nixhooks-no-such-tool /nonexistent/bin/mytool '' >"$TEST_REPO/out" || true [ "$NIXHOOKS_FAILED" -eq 1 ] [[ "$(cat "$TEST_REPO/out")" == *"nixhooks: FAIL myhook (nixhooks-no-such-tool not found)"* ]]}@test "run_hook only warns when a non-required hook's tool is missing" { NIXHOOKS_FAILED=0 NIXHOOKS_FILES=("a.sh") run_hook myhook '\.sh$' '^$' 1 0 0 "" /nonexistent/bin/mytool '' >"$TEST_REPO/out" [ "$NIXHOOKS_FAILED" -eq 0 ] [[ "$(cat "$TEST_REPO/out")" == *"nixhooks: WARN myhook (mytool not found, not required)"* ]]}@test "nixhooks_tool prefers the store binary, then PATH" { mkdir -p "$TEST_REPO/bin" cp "$RECORDER" "$TEST_REPO/bin/mytool" run nixhooks_tool "$RECORDER" mytool [ "$output" = "$RECORDER" ] PATH="$TEST_REPO/bin:$PATH" run nixhooks_tool /nonexistent/bin/mytool mytool [ "$output" = "$TEST_REPO/bin/mytool" ]}@test "nixhooks_tool fails with 127 when the tool is missing" { run nixhooks_tool /nonexistent/bin/mytool nixhooks-no-such-tool [ "$status" -eq 127 ] [[ "$output" == *"nixhooks: nixhooks-no-such-tool not found"* ]]}# script-gen.nix turns `script` hooks into functions passed as the entry@test "run_hook runs a function entry with the matched files" { nixhooks_hook_myhook() (printf '%s\n' "$@" >"$RECORD_FILE") NIXHOOKS_FILES=("a.sh" "b.txt") run_hook myhook '\.sh$' '^$' 1 0 1 "" nixhooks_hook_myhook '' --flag [ "$(cat "$RECORD_FILE")" = $'--flag\na.sh' ]}@test "a failing non-required hook doesn't fail the run, serial or parallel" { NIXHOOKS_FAILED=0 NIXHOOKS_FILES=("a.sh") run_hook serial '\.sh$' '^$' 1 0 0 "" false '' >"$TEST_REPO/out" run_hook_parallel par '\.sh$' '^$' 1 0 0 "" false '' nixhooks_wait_parallel >>"$TEST_REPO/out" [ "$NIXHOOKS_FAILED" -eq 0 ] [[ "$(cat "$TEST_REPO/out")" == *"nixhooks: WARN serial (failed, not required)"* ]] [[ "$(cat "$TEST_REPO/out")" == *"nixhooks: WARN par (failed, not required)"* ]]}# run_hook_parallel / nixhooks_wait_parallel ## NOTE: none of these wrap run_hook_parallel or nixhooks_wait_parallel in# bats' `run` (or `$(...)`) helper -- both fork a subshell, and a subshell# cannot `wait` on a background job that was started by its parent, since# process parentage doesn't follow the fork. Output is captured via plain# redirection instead, which doesn't fork.@test "run_hook_parallel + nixhooks_wait_parallel runs every job to completion" { local recorder_b="$TEST_REPO/.recorder-b.sh" local record_file_b="$TEST_REPO/.record-b" cat >"$recorder_b" <<EOF#!/bin/shprintf '%s\n' "\$@" >"$record_file_b"EOF chmod +x "$recorder_b" NIXHOOKS_FILES=("a.sh") run_hook_parallel jobA '\.sh$' '^$' 1 0 1 "" "$RECORDER" '' run_hook_parallel jobB '\.sh$' '^$' 1 0 1 "" "$recorder_b" '' nixhooks_wait_parallel run cat "$RECORD_FILE" [[ "$output" == *"a.sh"* ]] run cat "$record_file_b" [[ "$output" == *"a.sh"* ]]}@test "nixhooks_wait_parallel sets NIXHOOKS_FAILED when any job fails" { NIXHOOKS_FAILED=0 NIXHOOKS_FILES=() run_hook_parallel jobA '.*' '^$' 1 1 1 "" true '' run_hook_parallel jobB '.*' '^$' 1 1 1 "" false '' nixhooks_wait_parallel [ "$NIXHOOKS_FAILED" -eq 1 ]}@test "nixhooks_wait_parallel does not clear a pre-existing NIXHOOKS_FAILED when every job succeeds" { NIXHOOKS_FAILED=1 NIXHOOKS_FILES=() run_hook_parallel jobA '.*' '^$' 1 1 1 "" true '' nixhooks_wait_parallel [ "$NIXHOOKS_FAILED" -eq 1 ]}@test "nixhooks_wait_parallel flushes output in launch order regardless of completion order" { local slow="$TEST_REPO/.slow.sh" cat >"$slow" <<'EOF'#!/bin/shsleep 0.3echo SLOWEOF chmod +x "$slow" local fast="$TEST_REPO/.fast.sh" cat >"$fast" <<'EOF'#!/bin/shecho FASTEOF chmod +x "$fast" local wait_output="$TEST_REPO/.wait-output" NIXHOOKS_FILES=() run_hook_parallel slowjob '.*' '^$' 1 1 1 "" "$slow" '' run_hook_parallel fastjob '.*' '^$' 1 1 1 "" "$fast" '' nixhooks_wait_parallel >"$wait_output" 2>&1 run cat "$wait_output" [[ "$output" == *SLOW*FAST* ]]}@test "run_hook_parallel honors the same files/exclude matching as run_hook" { NIXHOOKS_FILES=("a.sh" "vendor/b.sh") run_hook_parallel myhook '\.sh$' '^vendor/' 1 0 1 "" "$RECORDER" '' nixhooks_wait_parallel run cat "$RECORD_FILE" [[ "$output" == *"a.sh"* ]] [[ "$output" != *"vendor/b.sh"* ]]}# set -e / errexit interaction ## The generated scripts run under `set -euo pipefail`. This is the property# lib/script-gen.nix's `|| true` guard on serial run_hook calls exists for:# a failing hook must not abort the rest of the script.@test "a failing hook does not abort a script running under set -euo pipefail" { run bash -c ' set -euo pipefail source "'"$NIXHOOKS_LIB"'/hooks-runtime.sh" NIXHOOKS_FILES=("a.sh") run_hook first "\.sh\$" "^\$" 1 0 1 "" false "" || true run_hook second "\.sh\$" "^\$" 1 0 1 "" true "" || true nixhooks_summary ' [ "$status" -eq 1 ] [[ "$output" == *"nixhooks: FAIL first"* ]] [[ "$output" == *"nixhooks: run second"* ]] [[ "$output" == *"one or more hooks failed"* ]]}# match cache@test "hooks sharing a files/exclude pair reuse one cached match" { NIXHOOKS_FILES=("a.sh" "b.nix" "vendor/c.sh") nixhooks_precompute_matches '\.sh$' '^vendor/' local first="$NIXHOOKS_MATCH_FILE" nixhooks_precompute_matches '\.nix$' '^$' nixhooks_precompute_matches '\.sh$' '^vendor/' [ "$NIXHOOKS_MATCH_FILE" = "$first" ] [ "$(find "$NIXHOOKS_MATCH_CACHE_DIR" -type f | wc -l)" -eq 2 ] run_hook myhook '\.sh$' '^vendor/' 1 0 1 "" "$RECORDER" '' [ "$(cat "$RECORD_FILE")" = "a.sh" ]}@test "the match cache keeps filenames with spaces and newlines intact" { NIXHOOKS_FILES=("a b.sh" $'c\nd.sh' "e.txt") run_hook myhook '\.sh$' '^$' 1 0 1 "" "$RECORDER" '' [ "$(cat "$RECORD_FILE")" = $'a b.sh\nc\nd.sh' ]}@test "nixhooks_summary exits 0 when nothing failed" { NIXHOOKS_FAILED=0 run nixhooks_summary [ "$status" -eq 0 ]}@test "nixhooks_summary exits 1 when a hook failed" { NIXHOOKS_FAILED=1 run nixhooks_summary [ "$status" -eq 1 ]}# file discovery @test "precommit file collection works with no HEAD yet (initial commit)" { echo hi >a.txt git add a.txt nixhooks_collect_precommit_files [ "${#NIXHOOKS_FILES[@]}" -eq 1 ] [ "${NIXHOOKS_FILES[0]}" = "a.txt" ]}@test "precommit file collection diffs against HEAD once it exists" { echo hi >a.txt git add a.txt git commit -q -m init echo bye >b.txt git add b.txt nixhooks_collect_precommit_files [ "${#NIXHOOKS_FILES[@]}" -eq 1 ] [ "${NIXHOOKS_FILES[0]}" = "b.txt" ]}@test "repo file collection lists every tracked file" { echo hi >a.txt echo bye >b.txt git add a.txt b.txt git commit -q -m init nixhooks_collect_repo_files [ "${#NIXHOOKS_FILES[@]}" -eq 2 ]}@test "commitmsg file collection sets NIXHOOKS_FILES to the given message file path" { local msg_file="$TEST_REPO/COMMIT_EDITMSG" echo "feat: add thing" >"$msg_file" nixhooks_collect_commitmsg_files "$msg_file" [ "${#NIXHOOKS_FILES[@]}" -eq 1 ] [ "${NIXHOOKS_FILES[0]}" = "$msg_file" ]}@test "prepush file collection falls back to the whole repo for a new branch (zero remote sha)" { echo hi >a.txt git add a.txt git commit -q -m init local_sha="$(git rev-parse HEAD)" zero_sha="0000000000000000000000000000000000000000" nixhooks_collect_prepush_files "$zero_sha" "$local_sha" [ "${#NIXHOOKS_FILES[@]}" -eq 1 ] [ "${NIXHOOKS_FILES[0]}" = "a.txt" ]}@test "prepush file collection diffs between two known shas" { echo hi >a.txt git add a.txt git commit -q -m init base_sha="$(git rev-parse HEAD)" echo bye >b.txt git add b.txt git commit -q -m second head_sha="$(git rev-parse HEAD)" nixhooks_collect_prepush_files "$base_sha" "$head_sha" [ "${#NIXHOOKS_FILES[@]}" -eq 1 ] [ "${NIXHOOKS_FILES[0]}" = "b.txt" ]}@test "prepush file collection excludes files deleted between the two shas" { echo hi >a.txt echo bye >deleteme.txt git add a.txt deleteme.txt git commit -q -m init base_sha="$(git rev-parse HEAD)" git rm -q deleteme.txt git commit -q -m "delete file" head_sha="$(git rev-parse HEAD)" nixhooks_collect_prepush_files "$base_sha" "$head_sha" [ "${#NIXHOOKS_FILES[@]}" -eq 0 ]}# worktree isolation @test "enter_worktree checks out the given commit, ignoring the dirty working tree" { echo committed >a.txt git add a.txt git commit -q -m init sha="$(git rev-parse HEAD)" echo dirty >a.txt echo untracked >untracked.txt run bash -c ' source "$NIXHOOKS_LIB/hooks-runtime.sh" nixhooks_enter_worktree "$1" [[ "$(pwd)" != "$NIXHOOKS_REPO_ROOT" ]] || exit 1 cat a.txt [[ ! -e untracked.txt ]] || exit 1 ' _ "$sha" [ "$status" -eq 0 ] [ "$output" = "committed" ]}@test "leave_worktree returns to the repo and removes the worktree" { echo hi >a.txt git add a.txt git commit -q -m init sha="$(git rev-parse HEAD)" run bash -c ' source "$NIXHOOKS_LIB/hooks-runtime.sh" nixhooks_enter_worktree "$1" wt="$NIXHOOKS_WORKTREE_DIR" nixhooks_leave_worktree [[ "$(pwd)" == "$NIXHOOKS_REPO_ROOT" ]] || exit 1 [[ ! -e "$wt" ]] || exit 1 [[ -z "$NIXHOOKS_WORKTREE_DIR" ]] || exit 1 ' _ "$sha" [ "$status" -eq 0 ] [ "$(git worktree list | wc -l)" -eq 1 ]}@test "hook edits inside a pre-push worktree never reach the real working tree" { echo hi >a.txt git add a.txt git commit -q -m init sha="$(git rev-parse HEAD)" bash -c ' source "$NIXHOOKS_LIB/hooks-runtime.sh" nixhooks_enter_worktree "$1" echo mutated >a.txt nixhooks_leave_worktree ' _ "$sha" [ "$(cat a.txt)" = "hi" ] git diff --quiet}@test "enter_worktree restores GIT_INDEX_FILE on return" { echo hi >a.txt git add a.txt git commit -q -m init sha="$(git rev-parse HEAD)" run env GIT_INDEX_FILE=.git/index bash -c ' source "$NIXHOOKS_LIB/hooks-runtime.sh" nixhooks_enter_worktree "$1" [[ -z "${GIT_INDEX_FILE-}" ]] || exit 1 nixhooks_leave_worktree [[ "$GIT_INDEX_FILE" == .git/index ]] || exit 1 ' _ "$sha" [ "$status" -eq 0 ]}# Regression: git sets only GIT_INDEX_FILE for pre-commit, and the unset# GIT_DIR / GIT_WORK_TREE restores used to fail the hook under `set -e`.@test "leave_worktree does not abort a set -e script when only GIT_INDEX_FILE was set" { echo hi >a.txt git add a.txt git commit -q -m init sha="$(git rev-parse HEAD)" run env GIT_INDEX_FILE=.git/index bash -c ' set -euo pipefail source "$NIXHOOKS_LIB/hooks-runtime.sh" nixhooks_enter_worktree "$1" nixhooks_leave_worktree echo returned ' _ "$sha" [ "$status" -eq 0 ] [[ "$output" == *returned ]]}@test "leave_worktree does not abort a set -e script when no git env vars were set" { echo hi >a.txt git add a.txt git commit -q -m init sha="$(git rev-parse HEAD)" run env -u GIT_INDEX_FILE -u GIT_DIR -u GIT_WORK_TREE bash -c ' set -euo pipefail source "$NIXHOOKS_LIB/hooks-runtime.sh" nixhooks_enter_worktree "$1" nixhooks_leave_worktree echo returned ' _ "$sha" [ "$status" -eq 0 ] [[ "$output" == *returned ]]}@test "the pre-commit driver flow completes under set -euo pipefail with only GIT_INDEX_FILE set" { echo hi >a.txt git add a.txt # Same sequence as lib/drivers/pre-commit.sh, minus the generated hooks. run env GIT_INDEX_FILE=.git/index bash -c ' set -euo pipefail source "$NIXHOOKS_LIB/hooks-runtime.sh" nixhooks_collect_precommit_files nixhooks_enter_staged_worktree nixhooks_reconcile_worktree nixhooks_summary echo returned ' [ "$status" -eq 0 ] [[ "$output" == *returned ]] [ "$(git worktree list | wc -l)" -eq 1 ]}# bash < 4.4 treats the empty NIXHOOKS_FILES as unbound under set -u@test "the pre-commit driver flow completes under set -euo pipefail for a deletion-only commit" { echo hi >a.txt git add a.txt git commit -qm init git rm -q a.txt run bash -c ' set -euo pipefail source "$NIXHOOKS_LIB/hooks-runtime.sh" nixhooks_collect_precommit_files nixhooks_enter_staged_worktree nixhooks_reconcile_worktree nixhooks_summary echo returned ' [ "$status" -eq 0 ] [[ "$output" == *returned ]]}