# 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/sh printf '%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/sh exit 0 EOF chmod +x "$extra_bin_dir/sibling-tool" local checker="$TEST_REPO/checker.sh" cat >"$checker" <<'EOF' #!/bin/sh command -v sibling-tool EOF 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/sh command -v sibling-tool EOF 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" <"$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/sh sleep 0.3 echo SLOW EOF chmod +x "$slow" local fast="$TEST_REPO/.fast.sh" cat >"$fast" <<'EOF' #!/bin/sh echo FAST EOF 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 ]] }