From f0b4892caf18dc14571606b23bd6809688dbbba7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 17:43:33 +0000 Subject: [PATCH] test(hooks): add the missing self-test matrix for guard-main-checkout.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Edit/Write/NotebookEdit half of the worktree-first guard shipped with no matrix at all, while its Bash sibling carries 121 cases. Once .claude/hooks/ self-tests run in CI, that directory reads as covered while this hook stays unchecked — and this hook's failure direction is the silent one. Cases are derived from what the hook decides (a path-and-worktree decision), not ported from the sibling, which is mostly shell splitting. 87 cases over eleven classes; every fixture default is a directory in no repo at all, so a case expecting `block` cannot pass by accident. The hook itself is untouched. Two real defects the matrix uncovered are filed separately and pinned here as labelled known holes so CI stays green and the fixes flip the cases mechanically. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx --- .claude/hooks/guard-main-checkout.selftest.sh | 320 ++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100755 .claude/hooks/guard-main-checkout.selftest.sh diff --git a/.claude/hooks/guard-main-checkout.selftest.sh b/.claude/hooks/guard-main-checkout.selftest.sh new file mode 100755 index 0000000000..60d985eebe --- /dev/null +++ b/.claude/hooks/guard-main-checkout.selftest.sh @@ -0,0 +1,320 @@ +#!/usr/bin/env bash +# Self-test for guard-main-checkout.sh — run it after touching that hook: +# +# .claude/hooks/guard-main-checkout.selftest.sh +# +# Feeds the hook the same JSON payload shape Claude Code delivers on PreToolUse and asserts +# the block/allow verdict per case. Hermetic: it builds its OWN throwaway git repos, a linked +# worktree of each and a non-repo directory under $TMPDIR, so the matrix never depends on +# which machine or which checkout it runs from. Needs jq and git and nothing else — no +# install, no build, no network. Exit 0 = all cases hold. +# +# Companion to guard-main-checkout-bash.selftest.sh, which covers the Bash half of the same +# worktree-first pair. That matrix is mostly SHELL SPLITTING; this hook parses no shell at +# all — it reads .tool_input.file_path and makes a PATH-AND-WORKTREE decision — so these +# cases are derived from what this hook actually decides, not ported from the sibling. +# +# Fail-open by default, on purpose: the process cwd AND CLAUDE_PROJECT_DIR both default to a +# directory in no repo at all, which is the input on which this hook allows everything. A +# case that expects `block` therefore cannot pass by accident — the verdict can only have +# come from the path in the payload. Sections that need a different default say so. +# +# GUARD_MAIN_CHECKOUT_HOOK points the matrix at a scratch copy of the hook, and +# GUARD_MAIN_CHECKOUT_SETTINGS at a scratch copy of settings.json; that is how the mutation +# runs that prove these cases can fail are driven (see NON-VACUITY at the foot of this file). +# Both default to the real files, so a plain invocation checks the real hook and real wiring. + +set -uo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +hook="${GUARD_MAIN_CHECKOUT_HOOK:-$here/guard-main-checkout.sh}" +settings="${GUARD_MAIN_CHECKOUT_SETTINGS:-$here/../settings.json}" +pass=0 +fail=0 +skip=0 + +command -v jq >/dev/null 2>&1 || { echo "selftest needs jq to build payloads" >&2; exit 1; } +command -v git >/dev/null 2>&1 || { echo "selftest needs git to build the fixture" >&2; exit 1; } +[ -x "$hook" ] || { echo "hook not executable: $hook" >&2; exit 1; } + +# --- fixture --------------------------------------------------------------------------- +# MAIN a shared PRIMARY checkout WT a linked worktree of MAIN +# SIB a SECOND primary checkout SIBWT a linked worktree of SIB +# PLAIN a directory inside no repo at all ODD a PRIMARY checkout whose own path +# carries a literal `worktrees` segment +tmp="$(mktemp -d)" +nojq="" +trap 'rm -rf "$tmp" ${nojq:+"$nojq"}' EXIT INT TERM +MAIN="$tmp/mainrepo" +WT="$tmp/mainrepo-task" +SIB="$tmp/siblingrepo" +SIBWT="$tmp/siblingrepo-task" +PLAIN="$tmp/plain" +ODD="$tmp/worktrees/oddrepo" +mkdir -p "$MAIN/pkg/deep" "$SIB/pkg" "$PLAIN" "$ODD/pkg" +( + for r in "$MAIN" "$SIB" "$ODD"; do + cd "$r" || exit 1 + git init -q . + git config user.email selftest@example.com + git config user.name selftest + : > README.md + mkdir -p pkg + : > pkg/x.ts + : > pkg/x.ipynb + git add -A + git commit -qm init + done + cd "$MAIN" && git worktree add -q "$WT" -b selftest-wt + cd "$SIB" && git worktree add -q "$SIBWT" -b selftest-sib-wt +) >/dev/null 2>&1 || { echo "could not build the git fixture" >&2; exit 1; } + +CWD="$PLAIN" # the hook's PROCESS cwd for the cases that follow; reassigned per section +PROJ="$PLAIN" # CLAUDE_PROJECT_DIR for the cases that follow; reassigned per section + +short() { # short -> fixture paths rendered as their variable names + local s="$1" + # longest paths first: $WT and $SIBWT have $MAIN and $SIB as prefixes + s="${s//$SIBWT/\$SIBWT}"; s="${s//$WT/\$WT}"; s="${s//$SIB/\$SIB}" + s="${s//$MAIN/\$MAIN}"; s="${s//$PLAIN/\$PLAIN}"; s="${s//$ODD/\$ODD}" + s="${s//$tmp/\$tmp}" + printf '%s' "$s" +} + +verdict() { # verdict [env…] -> block | allow | exitN + local payload="$1"; shift + local rc + ( cd "$CWD" && printf '%s' "$payload" | env CLAUDE_PROJECT_DIR="$PROJ" "$@" "$hook" >/dev/null 2>&1 ) + rc=$? + case "$rc" in + 0) printf 'allow' ;; + 2) printf 'block' ;; + *) printf 'exit%s' "$rc" ;; + esac +} + +check() { # check