#!/usr/bin/env bash # PreToolUse hook: blocks `git` invocations when the working directory # is inside a Mercurial (.hg) repository. # # Reads tool-call JSON from stdin, exits 0 to allow, exits 2 with a # message on stderr to block. set -u # Fail open if jq is missing — better to allow a command than to silently # block all bash for a missing dependency. if ! command -v jq >/dev/null 2>&1; then echo "isurus-hg hook: 'jq' not found; allowing command. Install jq to enable git-blocking." >&2 exit 0 fi input=$(cat) cmd=$(jq -r '.tool_input.command // ""' <<<"$input") cwd=$(jq -r '.cwd // ""' <<<"$input") # Allow if no command extracted. if [[ -z "$cmd" ]]; then exit 0 fi # Strip leading whitespace and any leading VAR=value env assignments. trimmed=$(sed -E 's/^[[:space:]]+//' <<<"$cmd") while [[ "$trimmed" =~ ^[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+ ]]; do trimmed=$(sed -E 's/^[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+//' <<<"$trimmed") done # Extract the leading word. first=$(awk '{print $1}' <<<"$trimmed") if [[ "$first" != "git" ]]; then exit 0 fi # Find VCS context for cwd: the closest .hg or .git ancestor. # If there's no usable cwd, allow. if [[ -z "$cwd" || ! -d "$cwd" || "$cwd" != /* ]]; then exit 0 fi # Bare git repo check: cwd contains HEAD + refs/ + objects/ at the top level # (the directory itself IS the git metadata; there's no .git subdir). if [[ -e "$cwd/HEAD" && -d "$cwd/refs" && -d "$cwd/objects" ]]; then exit 0 fi dir=$cwd hg_root="" git_root="" while [[ "$dir" != "/" && -n "$dir" ]]; do if [[ -z "$hg_root" && -d "$dir/.hg" ]]; then hg_root=$dir fi if [[ -z "$git_root" && -d "$dir/.git" ]]; then git_root=$dir fi if [[ -n "$hg_root" || -n "$git_root" ]]; then break fi dir=$(dirname "$dir") done # If a .git was found anywhere up the walk, allow. The loop breaks at # the first iteration where either is found, so a non-empty git_root means # .git is at the same level as .hg or closer — either way, the user is in # a git context. if [[ -n "$git_root" ]]; then exit 0 fi # No .hg ancestor => allow (e.g. plain `git --version` outside any repo). if [[ -z "$hg_root" ]]; then exit 0 fi # We are inside an .hg repo and the command starts with `git`. Block. # Build a suggestion based on the next word. sub=$(awk '{print $2}' <<<"$trimmed") case "$sub" in status) suggest="hg status" ;; add) suggest="hg add" ;; commit) suggest="hg commit -m \"...\" (no -u; identity comes from ~/.hgrc)" ;; log) suggest="hg log" ;; diff) suggest="hg diff" ;; branch) suggest="hg bookmarks (in this workflow, bookmarks are feature branches)" ;; checkout) suggest="hg update " ;; pull) suggest="hg pull -u" ;; push) suggest="hg push -B " ;; blame) suggest="hg annotate" ;; *) suggest="See the mercurial-basics skill for the full command map." ;; esac cat >&2 <&1 >/dev/null) rc=$? if [[ $rc -ne 2 ]]; then echo "expected exit 2 (blocked), got $rc" echo "stderr was: $err" exit 1 fi if ! grep -q "hg status" <<<"$err"; then echo "expected stderr to suggest 'hg status', got: $err" exit 1 fi #!/usr/bin/env bash # Case 02: 'git --version' outside any .hg/.git context must be allowed. set -u tmp=$(mktemp -d) trap 'rm -rf "$tmp"' EXIT input=$(jq -n --arg cwd "$tmp" --arg cmd "git --version" '{ cwd: $cwd, hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command: $cmd } }') if ! echo "$input" | "$HOOK_SCRIPT" >/dev/null 2>&1; then echo "expected exit 0 (allow), got non-zero" exit 1 fi #!/usr/bin/env bash # Case 03: closer .git wins over outer .hg, AND bare git repos # (HEAD/refs/objects in cwd, no .git subdir) inside an .hg ancestor allow. set -u tmp=$(mktemp -d) trap 'rm -rf "$tmp"' EXIT # Sub-case A: regular .git nested under .hg mkdir -p "$tmp/outer/.hg" mkdir -p "$tmp/outer/inner/.git" input=$(jq -n --arg cwd "$tmp/outer/inner" --arg cmd "git log" '{ cwd: $cwd, hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command: $cmd } }') if ! echo "$input" | "$HOOK_SCRIPT" >/dev/null 2>&1; then echo "sub-case A (nested .git) expected exit 0, got non-zero" exit 1 fi # Sub-case B: bare git repo (no .git subdir) inside .hg ancestor mkdir -p "$tmp/outer/testdata/repo.git/refs" mkdir -p "$tmp/outer/testdata/repo.git/objects" touch "$tmp/outer/testdata/repo.git/HEAD" input=$(jq -n --arg cwd "$tmp/outer/testdata/repo.git" --arg cmd "git log" '{ cwd: $cwd, hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command: $cmd } }') if ! echo "$input" | "$HOOK_SCRIPT" >/dev/null 2>&1; then echo "sub-case B (bare repo) expected exit 0, got non-zero" exit 1 fi #!/usr/bin/env bash # Case 04: 'hg status' inside an .hg repo must be allowed (only git is blocked). set -u tmp=$(mktemp -d) trap 'rm -rf "$tmp"' EXIT mkdir -p "$tmp/repo/.hg" input=$(jq -n --arg cwd "$tmp/repo" --arg cmd "hg status" '{ cwd: $cwd, hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command: $cmd } }') if ! echo "$input" | "$HOOK_SCRIPT" >/dev/null 2>&1; then echo "expected exit 0 (allow), got non-zero" exit 1 fi #!/usr/bin/env bash # Case 05: 'FOO=bar git status' inside an .hg repo must be blocked # (env-var prefix correctly skipped to find the real leading command). set -u tmp=$(mktemp -d) trap 'rm -rf "$tmp"' EXIT mkdir -p "$tmp/repo/.hg" input=$(jq -n --arg cwd "$tmp/repo" --arg cmd "FOO=bar GIT_PAGER=cat git status" '{ cwd: $cwd, hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command: $cmd } }') err=$(echo "$input" | "$HOOK_SCRIPT" 2>&1 >/dev/null) rc=$? if [[ $rc -ne 2 ]]; then echo "expected exit 2 (blocked), got $rc" echo "stderr: $err" exit 1 fi #!/usr/bin/env bash # Case 06: 'ls -la' inside an .hg repo must be allowed (not a git command). set -u tmp=$(mktemp -d) trap 'rm -rf "$tmp"' EXIT mkdir -p "$tmp/repo/.hg" input=$(jq -n --arg cwd "$tmp/repo" --arg cmd "ls -la" '{ cwd: $cwd, hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command: $cmd } }') if ! echo "$input" | "$HOOK_SCRIPT" >/dev/null 2>&1; then echo "expected exit 0 (allow), got non-zero" exit 1 fi #!/usr/bin/env bash # Case 07: 'cd /tmp && git init' inside an .hg repo must be allowed # (only the leading token is inspected — this is the documented escape valve). set -u tmp=$(mktemp -d) trap 'rm -rf "$tmp"' EXIT mkdir -p "$tmp/repo/.hg" input=$(jq -n --arg cwd "$tmp/repo" --arg cmd "cd /tmp && git init" '{ cwd: $cwd, hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command: $cmd } }') if ! echo "$input" | "$HOOK_SCRIPT" >/dev/null 2>&1; then echo "expected exit 0 (allow), got non-zero" exit 1 fi #!/usr/bin/env bash # Runs all hook test cases. Each case is a self-contained shell script # that exits 0 on PASS, non-zero on FAIL. # # Cases live in hooks/test/cases/*.sh, are run alphabetically, and # can use the helper functions/env defined here. set -u # Resolve repo root regardless of where this is invoked from. script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) repo_root=$(cd "$script_dir/../.." && pwd) HOOK_SCRIPT="$repo_root/hooks/block-git-in-hg.sh" export HOOK_SCRIPT if [[ ! -x "$HOOK_SCRIPT" ]]; then echo "FATAL: hook script not found or not executable: $HOOK_SCRIPT" exit 2 fi passed=0 failed=0 failed_cases=() shopt -s nullglob for case_file in "$script_dir"/cases/*.sh; do case_name=$(basename "$case_file" .sh) if bash "$case_file" >/tmp/hook_test_out.$$ 2>&1; then echo "PASS $case_name" passed=$((passed + 1)) else echo "FAIL $case_name" sed 's/^/ | /' /tmp/hook_test_out.$$ failed=$((failed + 1)) failed_cases+=("$case_name") fi rm -f /tmp/hook_test_out.$$ done echo echo "Result: $passed passed, $failed failed" if (( failed > 0 )); then printf ' failed: %s\n' "${failed_cases[@]}" exit 1 fi exit 0