fix: reject incompatible timeout in generated hooks (#5522)

Select timeout or gtimeout only after a successful GNU coreutils identity probe, validate BEADS_HOOK_TIMEOUT as positive whole seconds, and preserve the Perl and warned direct fallbacks under inherited shell options.

Keep GNU and Perl deadlines soft and backend-scoped, preserve natural status 137, regenerate every tracked hook artifact, and exercise the real process boundary through the required three-host preflight.

Fixes #5503

Agent-Signature: codex-gpt-5-unknown-reasoning on behalf of Ewen Cuthiell
This commit is contained in:
ecuthiell
2026-08-10 21:47:53 -06:00
committed by GitHub
parent 1e30815156
commit 766e6c17f2
12 changed files with 1120 additions and 128 deletions
+41 -15
View File
@@ -4,30 +4,56 @@
if command -v bd >/dev/null 2>&1; then
export BD_GIT_HOOK=1
_bd_timeout=${BEADS_HOOK_TIMEOUT:-300}
_bd_used_perl=0
if command -v timeout >/dev/null 2>&1; then
timeout "$_bd_timeout" bd hooks run post-checkout "$@"
_bd_exit=$?
elif command -v gtimeout >/dev/null 2>&1; then
gtimeout "$_bd_timeout" bd hooks run post-checkout "$@"
_bd_exit=$?
case "$_bd_timeout" in
*[!0-9]*|'') _bd_timeout_invalid=1 ;;
*[1-9]*) _bd_timeout_invalid=0 ;;
*) _bd_timeout_invalid=1 ;;
esac
if [ "$_bd_timeout_invalid" -eq 1 ]; then
echo >&2 "beads: invalid BEADS_HOOK_TIMEOUT; using 300 seconds"
_bd_timeout=300
fi
_bd_timeout_backend=none
_bd_timeout_command=
for _bd_timeout_candidate in timeout gtimeout; do
if command -v "$_bd_timeout_candidate" >/dev/null 2>&1; then
if _bd_timeout_version="$("$_bd_timeout_candidate" --version 2>/dev/null)"; then
case "$_bd_timeout_version" in
"timeout (GNU coreutils) "*) _bd_timeout_command=$_bd_timeout_candidate; break ;;
esac
fi
fi
done
if [ -n "$_bd_timeout_command" ]; then
_bd_timeout_backend=coreutils
if "$_bd_timeout_command" -- "$_bd_timeout" bd hooks run post-checkout "$@"; then
_bd_exit=0
else
_bd_exit=$?
fi
elif command -v perl >/dev/null 2>&1; then
_bd_used_perl=1
perl -e 'alarm shift; exec @ARGV' "$_bd_timeout" bd hooks run post-checkout "$@"
_bd_exit=$?
_bd_timeout_backend=perl
if perl -e 'alarm shift; exec @ARGV' -- "$_bd_timeout" bd hooks run post-checkout "$@"; then
_bd_exit=0
else
_bd_exit=$?
fi
else
echo >&2 "beads: hook 'post-checkout' running without timeout; install coreutils or perl to enable BEADS_HOOK_TIMEOUT"
bd hooks run post-checkout "$@"
_bd_exit=$?
if bd hooks run post-checkout "$@"; then
_bd_exit=0
else
_bd_exit=$?
fi
fi
if [ $_bd_exit -eq 124 ] || { [ $_bd_used_perl -eq 1 ] && [ $_bd_exit -eq 142 ]; }; then
if { [ "$_bd_timeout_backend" = coreutils ] && [ "$_bd_exit" -eq 124 ]; } || { [ "$_bd_timeout_backend" = perl ] && [ "$_bd_exit" -eq 142 ]; }; then
echo >&2 "beads: hook 'post-checkout' timed out after ${_bd_timeout}s — continuing without beads"
_bd_exit=0
fi
if [ $_bd_exit -eq 3 ]; then
if [ "$_bd_exit" -eq 3 ]; then
echo >&2 "beads: database not initialized — skipping hook 'post-checkout'"
_bd_exit=0
fi
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
if [ "$_bd_exit" -ne 0 ]; then exit "$_bd_exit"; fi
fi
# --- END BEADS INTEGRATION v1.1.0 ---
+41 -15
View File
@@ -4,30 +4,56 @@
if command -v bd >/dev/null 2>&1; then
export BD_GIT_HOOK=1
_bd_timeout=${BEADS_HOOK_TIMEOUT:-300}
_bd_used_perl=0
if command -v timeout >/dev/null 2>&1; then
timeout "$_bd_timeout" bd hooks run post-merge "$@"
_bd_exit=$?
elif command -v gtimeout >/dev/null 2>&1; then
gtimeout "$_bd_timeout" bd hooks run post-merge "$@"
_bd_exit=$?
case "$_bd_timeout" in
*[!0-9]*|'') _bd_timeout_invalid=1 ;;
*[1-9]*) _bd_timeout_invalid=0 ;;
*) _bd_timeout_invalid=1 ;;
esac
if [ "$_bd_timeout_invalid" -eq 1 ]; then
echo >&2 "beads: invalid BEADS_HOOK_TIMEOUT; using 300 seconds"
_bd_timeout=300
fi
_bd_timeout_backend=none
_bd_timeout_command=
for _bd_timeout_candidate in timeout gtimeout; do
if command -v "$_bd_timeout_candidate" >/dev/null 2>&1; then
if _bd_timeout_version="$("$_bd_timeout_candidate" --version 2>/dev/null)"; then
case "$_bd_timeout_version" in
"timeout (GNU coreutils) "*) _bd_timeout_command=$_bd_timeout_candidate; break ;;
esac
fi
fi
done
if [ -n "$_bd_timeout_command" ]; then
_bd_timeout_backend=coreutils
if "$_bd_timeout_command" -- "$_bd_timeout" bd hooks run post-merge "$@"; then
_bd_exit=0
else
_bd_exit=$?
fi
elif command -v perl >/dev/null 2>&1; then
_bd_used_perl=1
perl -e 'alarm shift; exec @ARGV' "$_bd_timeout" bd hooks run post-merge "$@"
_bd_exit=$?
_bd_timeout_backend=perl
if perl -e 'alarm shift; exec @ARGV' -- "$_bd_timeout" bd hooks run post-merge "$@"; then
_bd_exit=0
else
_bd_exit=$?
fi
else
echo >&2 "beads: hook 'post-merge' running without timeout; install coreutils or perl to enable BEADS_HOOK_TIMEOUT"
bd hooks run post-merge "$@"
_bd_exit=$?
if bd hooks run post-merge "$@"; then
_bd_exit=0
else
_bd_exit=$?
fi
fi
if [ $_bd_exit -eq 124 ] || { [ $_bd_used_perl -eq 1 ] && [ $_bd_exit -eq 142 ]; }; then
if { [ "$_bd_timeout_backend" = coreutils ] && [ "$_bd_exit" -eq 124 ]; } || { [ "$_bd_timeout_backend" = perl ] && [ "$_bd_exit" -eq 142 ]; }; then
echo >&2 "beads: hook 'post-merge' timed out after ${_bd_timeout}s — continuing without beads"
_bd_exit=0
fi
if [ $_bd_exit -eq 3 ]; then
if [ "$_bd_exit" -eq 3 ]; then
echo >&2 "beads: database not initialized — skipping hook 'post-merge'"
_bd_exit=0
fi
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
if [ "$_bd_exit" -ne 0 ]; then exit "$_bd_exit"; fi
fi
# --- END BEADS INTEGRATION v1.1.0 ---
+41 -19
View File
@@ -40,39 +40,61 @@ if [ -n "$staged_go_files" ]; then
echo "$staged_go_files" | xargs git add
fi
# The managed section below captures exit codes to tolerate timeouts and an
# uninitialized bd database; set -e would abort before that handling runs.
set +e
# --- BEGIN BEADS INTEGRATION v1.1.0 ---
# This section is managed by beads. Do not remove these markers.
if command -v bd >/dev/null 2>&1; then
export BD_GIT_HOOK=1
_bd_timeout=${BEADS_HOOK_TIMEOUT:-300}
_bd_used_perl=0
if command -v timeout >/dev/null 2>&1; then
timeout "$_bd_timeout" bd hooks run pre-commit "$@"
_bd_exit=$?
elif command -v gtimeout >/dev/null 2>&1; then
gtimeout "$_bd_timeout" bd hooks run pre-commit "$@"
_bd_exit=$?
case "$_bd_timeout" in
*[!0-9]*|'') _bd_timeout_invalid=1 ;;
*[1-9]*) _bd_timeout_invalid=0 ;;
*) _bd_timeout_invalid=1 ;;
esac
if [ "$_bd_timeout_invalid" -eq 1 ]; then
echo >&2 "beads: invalid BEADS_HOOK_TIMEOUT; using 300 seconds"
_bd_timeout=300
fi
_bd_timeout_backend=none
_bd_timeout_command=
for _bd_timeout_candidate in timeout gtimeout; do
if command -v "$_bd_timeout_candidate" >/dev/null 2>&1; then
if _bd_timeout_version="$("$_bd_timeout_candidate" --version 2>/dev/null)"; then
case "$_bd_timeout_version" in
"timeout (GNU coreutils) "*) _bd_timeout_command=$_bd_timeout_candidate; break ;;
esac
fi
fi
done
if [ -n "$_bd_timeout_command" ]; then
_bd_timeout_backend=coreutils
if "$_bd_timeout_command" -- "$_bd_timeout" bd hooks run pre-commit "$@"; then
_bd_exit=0
else
_bd_exit=$?
fi
elif command -v perl >/dev/null 2>&1; then
_bd_used_perl=1
perl -e 'alarm shift; exec @ARGV' "$_bd_timeout" bd hooks run pre-commit "$@"
_bd_exit=$?
_bd_timeout_backend=perl
if perl -e 'alarm shift; exec @ARGV' -- "$_bd_timeout" bd hooks run pre-commit "$@"; then
_bd_exit=0
else
_bd_exit=$?
fi
else
echo >&2 "beads: hook 'pre-commit' running without timeout; install coreutils or perl to enable BEADS_HOOK_TIMEOUT"
bd hooks run pre-commit "$@"
_bd_exit=$?
if bd hooks run pre-commit "$@"; then
_bd_exit=0
else
_bd_exit=$?
fi
fi
if [ $_bd_exit -eq 124 ] || { [ $_bd_used_perl -eq 1 ] && [ $_bd_exit -eq 142 ]; }; then
if { [ "$_bd_timeout_backend" = coreutils ] && [ "$_bd_exit" -eq 124 ]; } || { [ "$_bd_timeout_backend" = perl ] && [ "$_bd_exit" -eq 142 ]; }; then
echo >&2 "beads: hook 'pre-commit' timed out after ${_bd_timeout}s — continuing without beads"
_bd_exit=0
fi
if [ $_bd_exit -eq 3 ]; then
if [ "$_bd_exit" -eq 3 ]; then
echo >&2 "beads: database not initialized — skipping hook 'pre-commit'"
_bd_exit=0
fi
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
if [ "$_bd_exit" -ne 0 ]; then exit "$_bd_exit"; fi
fi
# --- END BEADS INTEGRATION v1.1.0 ---
+41 -18
View File
@@ -37,9 +37,6 @@ if [ "$pushing_version_tag" -eq 1 ]; then
fi
fi
# The managed section below captures exit codes to tolerate timeouts and an
# uninitialized bd database; set -e would abort before that handling runs.
set +e
exec < <(printf '%s\n' "$push_refs")
# --- BEGIN BEADS INTEGRATION v1.1.0 ---
@@ -47,30 +44,56 @@ exec < <(printf '%s\n' "$push_refs")
if command -v bd >/dev/null 2>&1; then
export BD_GIT_HOOK=1
_bd_timeout=${BEADS_HOOK_TIMEOUT:-300}
_bd_used_perl=0
if command -v timeout >/dev/null 2>&1; then
timeout "$_bd_timeout" bd hooks run pre-push "$@"
_bd_exit=$?
elif command -v gtimeout >/dev/null 2>&1; then
gtimeout "$_bd_timeout" bd hooks run pre-push "$@"
_bd_exit=$?
case "$_bd_timeout" in
*[!0-9]*|'') _bd_timeout_invalid=1 ;;
*[1-9]*) _bd_timeout_invalid=0 ;;
*) _bd_timeout_invalid=1 ;;
esac
if [ "$_bd_timeout_invalid" -eq 1 ]; then
echo >&2 "beads: invalid BEADS_HOOK_TIMEOUT; using 300 seconds"
_bd_timeout=300
fi
_bd_timeout_backend=none
_bd_timeout_command=
for _bd_timeout_candidate in timeout gtimeout; do
if command -v "$_bd_timeout_candidate" >/dev/null 2>&1; then
if _bd_timeout_version="$("$_bd_timeout_candidate" --version 2>/dev/null)"; then
case "$_bd_timeout_version" in
"timeout (GNU coreutils) "*) _bd_timeout_command=$_bd_timeout_candidate; break ;;
esac
fi
fi
done
if [ -n "$_bd_timeout_command" ]; then
_bd_timeout_backend=coreutils
if "$_bd_timeout_command" -- "$_bd_timeout" bd hooks run pre-push "$@"; then
_bd_exit=0
else
_bd_exit=$?
fi
elif command -v perl >/dev/null 2>&1; then
_bd_used_perl=1
perl -e 'alarm shift; exec @ARGV' "$_bd_timeout" bd hooks run pre-push "$@"
_bd_exit=$?
_bd_timeout_backend=perl
if perl -e 'alarm shift; exec @ARGV' -- "$_bd_timeout" bd hooks run pre-push "$@"; then
_bd_exit=0
else
_bd_exit=$?
fi
else
echo >&2 "beads: hook 'pre-push' running without timeout; install coreutils or perl to enable BEADS_HOOK_TIMEOUT"
bd hooks run pre-push "$@"
_bd_exit=$?
if bd hooks run pre-push "$@"; then
_bd_exit=0
else
_bd_exit=$?
fi
fi
if [ $_bd_exit -eq 124 ] || { [ $_bd_used_perl -eq 1 ] && [ $_bd_exit -eq 142 ]; }; then
if { [ "$_bd_timeout_backend" = coreutils ] && [ "$_bd_exit" -eq 124 ]; } || { [ "$_bd_timeout_backend" = perl ] && [ "$_bd_exit" -eq 142 ]; }; then
echo >&2 "beads: hook 'pre-push' timed out after ${_bd_timeout}s — continuing without beads"
_bd_exit=0
fi
if [ $_bd_exit -eq 3 ]; then
if [ "$_bd_exit" -eq 3 ]; then
echo >&2 "beads: database not initialized — skipping hook 'pre-push'"
_bd_exit=0
fi
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
if [ "$_bd_exit" -ne 0 ]; then exit "$_bd_exit"; fi
fi
# --- END BEADS INTEGRATION v1.1.0 ---
+41 -15
View File
@@ -4,30 +4,56 @@
if command -v bd >/dev/null 2>&1; then
export BD_GIT_HOOK=1
_bd_timeout=${BEADS_HOOK_TIMEOUT:-300}
_bd_used_perl=0
if command -v timeout >/dev/null 2>&1; then
timeout "$_bd_timeout" bd hooks run prepare-commit-msg "$@"
_bd_exit=$?
elif command -v gtimeout >/dev/null 2>&1; then
gtimeout "$_bd_timeout" bd hooks run prepare-commit-msg "$@"
_bd_exit=$?
case "$_bd_timeout" in
*[!0-9]*|'') _bd_timeout_invalid=1 ;;
*[1-9]*) _bd_timeout_invalid=0 ;;
*) _bd_timeout_invalid=1 ;;
esac
if [ "$_bd_timeout_invalid" -eq 1 ]; then
echo >&2 "beads: invalid BEADS_HOOK_TIMEOUT; using 300 seconds"
_bd_timeout=300
fi
_bd_timeout_backend=none
_bd_timeout_command=
for _bd_timeout_candidate in timeout gtimeout; do
if command -v "$_bd_timeout_candidate" >/dev/null 2>&1; then
if _bd_timeout_version="$("$_bd_timeout_candidate" --version 2>/dev/null)"; then
case "$_bd_timeout_version" in
"timeout (GNU coreutils) "*) _bd_timeout_command=$_bd_timeout_candidate; break ;;
esac
fi
fi
done
if [ -n "$_bd_timeout_command" ]; then
_bd_timeout_backend=coreutils
if "$_bd_timeout_command" -- "$_bd_timeout" bd hooks run prepare-commit-msg "$@"; then
_bd_exit=0
else
_bd_exit=$?
fi
elif command -v perl >/dev/null 2>&1; then
_bd_used_perl=1
perl -e 'alarm shift; exec @ARGV' "$_bd_timeout" bd hooks run prepare-commit-msg "$@"
_bd_exit=$?
_bd_timeout_backend=perl
if perl -e 'alarm shift; exec @ARGV' -- "$_bd_timeout" bd hooks run prepare-commit-msg "$@"; then
_bd_exit=0
else
_bd_exit=$?
fi
else
echo >&2 "beads: hook 'prepare-commit-msg' running without timeout; install coreutils or perl to enable BEADS_HOOK_TIMEOUT"
bd hooks run prepare-commit-msg "$@"
_bd_exit=$?
if bd hooks run prepare-commit-msg "$@"; then
_bd_exit=0
else
_bd_exit=$?
fi
fi
if [ $_bd_exit -eq 124 ] || { [ $_bd_used_perl -eq 1 ] && [ $_bd_exit -eq 142 ]; }; then
if { [ "$_bd_timeout_backend" = coreutils ] && [ "$_bd_exit" -eq 124 ]; } || { [ "$_bd_timeout_backend" = perl ] && [ "$_bd_exit" -eq 142 ]; }; then
echo >&2 "beads: hook 'prepare-commit-msg' timed out after ${_bd_timeout}s — continuing without beads"
_bd_exit=0
fi
if [ $_bd_exit -eq 3 ]; then
if [ "$_bd_exit" -eq 3 ]; then
echo >&2 "beads: database not initialized — skipping hook 'prepare-commit-msg'"
_bd_exit=0
fi
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
if [ "$_bd_exit" -ne 0 ]; then exit "$_bd_exit"; fi
fi
# --- END BEADS INTEGRATION v1.1.0 ---
+5 -1
View File
@@ -399,7 +399,7 @@ jobs:
pr-preflight-platforms:
name: PR preflight process (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 10
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
@@ -429,6 +429,10 @@ jobs:
shell: bash
run: go test '-tags=gms_pure_go' -count=1 -run '^TestTestScriptPrebuiltBinaryContract$' ./scripts
- name: Exercise generated Git hook timeout process boundary
shell: bash
run: go test '-tags=gms_pure_go' -count=1 -run '^TestGeneratedHookTimeoutProcessBoundary$' ./cmd/bd
# Fast check to catch accidental .beads/issues.jsonl changes from contributors
check-no-beads-changes:
name: Check for .beads changes
+55 -21
View File
@@ -45,9 +45,10 @@ func hookSectionEndLine() string {
return fmt.Sprintf("%s v%s ---", hookSectionEndPrefix, Version)
}
// hookTimeoutSeconds is the maximum time a beads hook is allowed to run before
// being killed and allowing the git operation to proceed. A bounded timeout
// prevents `bd hooks run` from hanging `git push` indefinitely (GH#2453).
// hookTimeoutSeconds is the soft deadline for a beads hook. GNU timeout sends
// SIGTERM at the deadline, while Perl's alarm terminates the direct bd process.
// Neither backend promises containment of TERM-resistant descendant work. When
// neither helper is available, the generated hook warns that it is unbounded.
// The default is 300 seconds (5 minutes) to accommodate chained hooks — e.g.
// pre-commit framework pipelines that run linters, type-checkers, and builds
// inside `bd hooks run` via the `.old` hook chain (GH#2732).
@@ -60,7 +61,13 @@ const hookTimeoutSeconds = 300
// content after the section from executing on success.
//
// Resilience (GH#2453, GH#2449):
// - A configurable timeout prevents hooks from hanging git operations.
// - A compatible timeout helper applies a best-effort soft deadline.
// - BEADS_HOOK_TIMEOUT accepts positive whole seconds only.
// - Helper argv is separated with -- so user input cannot become an option.
// - If no compatible helper exists, the direct fallback is explicitly
// unbounded rather than silently pretending to enforce a deadline.
// - Only GNU coreutils timeout implementations are selected; Windows
// timeout.exe has the same name but an incompatible command line (GH#5503).
// - If the beads database is not initialized (exit code 3), the hook exits
// successfully with a warning so that git operations are not blocked.
func generateHookSection(hookName string) string {
@@ -69,31 +76,57 @@ func generateHookSection(hookName string) string {
"if command -v bd >/dev/null 2>&1; then\n" +
" export BD_GIT_HOOK=1\n" +
" _bd_timeout=${BEADS_HOOK_TIMEOUT:-" + fmt.Sprintf("%d", hookTimeoutSeconds) + "}\n" +
" _bd_used_perl=0\n" +
" if command -v timeout >/dev/null 2>&1; then\n" +
" timeout \"$_bd_timeout\" bd hooks run " + hookName + " \"$@\"\n" +
" _bd_exit=$?\n" +
" elif command -v gtimeout >/dev/null 2>&1; then\n" +
" gtimeout \"$_bd_timeout\" bd hooks run " + hookName + " \"$@\"\n" +
" _bd_exit=$?\n" +
" case \"$_bd_timeout\" in\n" +
" *[!0-9]*|'') _bd_timeout_invalid=1 ;;\n" +
" *[1-9]*) _bd_timeout_invalid=0 ;;\n" +
" *) _bd_timeout_invalid=1 ;;\n" +
" esac\n" +
" if [ \"$_bd_timeout_invalid\" -eq 1 ]; then\n" +
" echo >&2 \"beads: invalid BEADS_HOOK_TIMEOUT; using " + fmt.Sprintf("%d", hookTimeoutSeconds) + " seconds\"\n" +
" _bd_timeout=" + fmt.Sprintf("%d", hookTimeoutSeconds) + "\n" +
" fi\n" +
" _bd_timeout_backend=none\n" +
" _bd_timeout_command=\n" +
" for _bd_timeout_candidate in timeout gtimeout; do\n" +
" if command -v \"$_bd_timeout_candidate\" >/dev/null 2>&1; then\n" +
" if _bd_timeout_version=\"$(\"$_bd_timeout_candidate\" --version 2>/dev/null)\"; then\n" +
" case \"$_bd_timeout_version\" in\n" +
" \"timeout (GNU coreutils) \"*) _bd_timeout_command=$_bd_timeout_candidate; break ;;\n" +
" esac\n" +
" fi\n" +
" fi\n" +
" done\n" +
" if [ -n \"$_bd_timeout_command\" ]; then\n" +
" _bd_timeout_backend=coreutils\n" +
" if \"$_bd_timeout_command\" -- \"$_bd_timeout\" bd hooks run " + hookName + " \"$@\"; then\n" +
" _bd_exit=0\n" +
" else\n" +
" _bd_exit=$?\n" +
" fi\n" +
" elif command -v perl >/dev/null 2>&1; then\n" +
" _bd_used_perl=1\n" +
" perl -e 'alarm shift; exec @ARGV' \"$_bd_timeout\" bd hooks run " + hookName + " \"$@\"\n" +
" _bd_exit=$?\n" +
" _bd_timeout_backend=perl\n" +
" if perl -e 'alarm shift; exec @ARGV' -- \"$_bd_timeout\" bd hooks run " + hookName + " \"$@\"; then\n" +
" _bd_exit=0\n" +
" else\n" +
" _bd_exit=$?\n" +
" fi\n" +
" else\n" +
" echo >&2 \"beads: hook '" + hookName + "' running without timeout; install coreutils or perl to enable BEADS_HOOK_TIMEOUT\"\n" +
" bd hooks run " + hookName + " \"$@\"\n" +
" _bd_exit=$?\n" +
" if bd hooks run " + hookName + " \"$@\"; then\n" +
" _bd_exit=0\n" +
" else\n" +
" _bd_exit=$?\n" +
" fi\n" +
" fi\n" +
" if [ $_bd_exit -eq 124 ] || { [ $_bd_used_perl -eq 1 ] && [ $_bd_exit -eq 142 ]; }; then\n" +
" if { [ \"$_bd_timeout_backend\" = coreutils ] && [ \"$_bd_exit\" -eq 124 ]; } || { [ \"$_bd_timeout_backend\" = perl ] && [ \"$_bd_exit\" -eq 142 ]; }; then\n" +
" echo >&2 \"beads: hook '" + hookName + "' timed out after ${_bd_timeout}s — continuing without beads\"\n" +
" _bd_exit=0\n" +
" fi\n" +
" if [ $_bd_exit -eq 3 ]; then\n" +
" if [ \"$_bd_exit\" -eq 3 ]; then\n" +
" echo >&2 \"beads: database not initialized — skipping hook '" + hookName + "'\"\n" +
" _bd_exit=0\n" +
" fi\n" +
" if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi\n" +
" if [ \"$_bd_exit\" -ne 0 ]; then exit \"$_bd_exit\"; fi\n" +
"fi\n" +
hookSectionEndLine() + "\n"
}
@@ -1817,8 +1850,9 @@ Supported hooks:
- post-checkout: Run chained hooks after branch checkout
- prepare-commit-msg: Add agent identity trailers for forensics
The thin shim pattern ensures hook logic is always in sync with the
installed bd version - upgrading bd automatically updates hook behavior.`,
The thin shim keeps delegated hook logic in sync with the installed bd
version. Upgrading bd updates that delegated behavior. To adopt changes to the
shim's generated shell policy, refresh it with 'bd hooks install'.`,
Args: cobra.MinimumNArgs(1),
SilenceUsage: true,
SilenceErrors: true,
+715
View File
@@ -0,0 +1,715 @@
package main
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
"time"
)
const hookProcessBDStub = `#!/bin/sh
printf 'bd-argc=%s\n' "$#"
_bd_test_index=0
for _bd_test_arg
do
printf 'bd-arg-%s=<%s>\n' "$_bd_test_index" "$_bd_test_arg"
_bd_test_index=$((_bd_test_index + 1))
done
exit "${HOOK_TEST_BD_EXIT:-0}"
`
const hookProcessLongRunningBDStub = `#!/bin/sh
printf 'long-running-bd-started\n'
while :; do :; done
`
const hookProcessGNUTimeoutStub = `#!/bin/sh
if [ "${1-}" = "--version" ]; then
printf 'timeout (GNU coreutils) 9.99\n'
exit 0
fi
if [ "${1-}" != "--" ] || [ "$#" -lt 6 ]; then
printf 'invalid-timeout-argv\n' >&2
exit 96
fi
shift
printf 'helper=timeout\n'
printf 'duration=<%s>\n' "$1"
shift
exec "$@"
`
const hookProcessGNUGtimeoutStub = `#!/bin/sh
if [ "${1-}" = "--version" ]; then
printf 'timeout (GNU coreutils) 9.99\n'
exit 0
fi
if [ "${1-}" != "--" ] || [ "$#" -lt 6 ]; then
printf 'invalid-gtimeout-argv\n' >&2
exit 96
fi
shift
printf 'helper=gtimeout\n'
printf 'duration=<%s>\n' "$1"
shift
exec "$@"
`
const hookProcessIncompatibleTimeoutStub = `#!/bin/sh
if [ "${1-}" = "--version" ]; then
printf 'Microsoft Windows timeout\n'
exit 1
fi
printf 'hostile-timeout-invoked\n' >&2
exit 97
`
const hookProcessFailedGNUProbeStub = `#!/bin/sh
if [ "${1-}" = "--version" ]; then
printf 'timeout (GNU coreutils) 9.99\n'
exit 23
fi
printf 'failed-gnu-probe-invoked\n' >&2
exit 97
`
const hookProcessIncompatibleGtimeoutStub = `#!/bin/sh
if [ "${1-}" = "--version" ]; then
printf 'not GNU coreutils\n'
exit 0
fi
printf 'hostile-gtimeout-invoked\n' >&2
exit 98
`
const hookProcessPerlStub = `#!/bin/sh
if [ "${1-}" != "-e" ] || [ "${3-}" != "--" ] || [ "$#" -lt 8 ]; then
printf 'invalid-perl-argv\n' >&2
exit 96
fi
shift 3
printf 'helper=perl\n'
printf 'duration=<%s>\n' "$1"
shift
exec "$@"
`
type hookProcessFixture struct {
name string
body string
}
type hookProcessCase struct {
fixtures []hookProcessFixture
bdBody string
bdExit int
shellOption string
usePOSIXSh bool
timeout *string
pathTail string
args []string
}
type hookProcessResult struct {
output string
exitCode int
elapsed time.Duration
}
func TestGeneratedHookTimeoutProcessBoundary(t *testing.T) {
t.Run("backend selection", testHookProcessBackendSelection)
t.Run("positive timeout values", testHookProcessTimeoutValidation)
t.Run("shell options preserve argv and status", testHookProcessShellOptions)
t.Run("POSIX sh preserves argv and status", testHookProcessPOSIXShell)
t.Run("reserved statuses are backend scoped", testHookProcessReservedStatuses)
t.Run("real GNU timeout expires a responsive child", testHookProcessRealTimeoutExpiry)
t.Run("real Perl alarm expires a responsive child", testHookProcessRealPerlExpiry)
t.Run("Windows checkout rejects System32 timeout", testHookProcessWindowsSystemTimeoutCheckout)
}
func testHookProcessBackendSelection(t *testing.T) {
tests := []struct {
name string
fixtures []hookProcessFixture
wantHelper string
wantWarning bool
}{
{
name: "incompatible timeout yields to gtimeout",
fixtures: []hookProcessFixture{
{name: "timeout", body: hookProcessIncompatibleTimeoutStub},
{name: "gtimeout", body: hookProcessGNUGtimeoutStub},
},
wantHelper: "helper=gtimeout",
},
{
name: "nonzero GNU-looking probe yields to gtimeout",
fixtures: []hookProcessFixture{
{name: "timeout", body: hookProcessFailedGNUProbeStub},
{name: "gtimeout", body: hookProcessGNUGtimeoutStub},
},
wantHelper: "helper=gtimeout",
},
{
name: "Perl follows incompatible timeout commands",
fixtures: []hookProcessFixture{
{name: "timeout", body: hookProcessIncompatibleTimeoutStub},
{name: "gtimeout", body: hookProcessIncompatibleGtimeoutStub},
{name: "perl", body: hookProcessPerlStub},
},
wantHelper: "helper=perl",
},
{
name: "direct fallback is explicit",
fixtures: []hookProcessFixture{
{name: "timeout", body: hookProcessIncompatibleTimeoutStub},
{name: "gtimeout", body: hookProcessIncompatibleGtimeoutStub},
},
wantWarning: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
timeout := "17"
args := []string{"remote with space", "https://example.invalid/repo"}
result := runGeneratedHookProcess(t, hookProcessCase{
fixtures: tt.fixtures,
timeout: &timeout,
args: args,
})
if result.exitCode != 0 {
t.Fatalf("generated hook exit = %d, want 0\n%s", result.exitCode, result.output)
}
if tt.wantHelper != "" && !strings.Contains(result.output, tt.wantHelper) {
t.Errorf("output missing selected helper %q\n%s", tt.wantHelper, result.output)
}
if got := strings.Contains(result.output, "running without timeout"); got != tt.wantWarning {
t.Errorf("direct-fallback warning presence = %v, want %v\n%s", got, tt.wantWarning, result.output)
}
for _, forbidden := range []string{
"hostile-timeout-invoked", "failed-gnu-probe-invoked", "hostile-gtimeout-invoked",
} {
if strings.Contains(result.output, forbidden) {
t.Errorf("incompatible helper was invoked: %s\n%s", forbidden, result.output)
}
}
assertHookProcessBDInvocation(t, result.output, "pre-push", args...)
})
}
}
func testHookProcessTimeoutValidation(t *testing.T) {
tests := []struct {
name string
value *string
wantSeconds string
wantWarning bool
}{
{name: "unset uses default", wantSeconds: strconv.Itoa(hookTimeoutSeconds)},
{name: "option-looking value", value: hookProcessString("--help"), wantSeconds: strconv.Itoa(hookTimeoutSeconds), wantWarning: true},
{name: "zero", value: hookProcessString("0"), wantSeconds: strconv.Itoa(hookTimeoutSeconds), wantWarning: true},
{name: "all-zero digits", value: hookProcessString("000"), wantSeconds: strconv.Itoa(hookTimeoutSeconds), wantWarning: true},
{name: "mixed value", value: hookProcessString("12s"), wantSeconds: strconv.Itoa(hookTimeoutSeconds), wantWarning: true},
{name: "positive whole seconds", value: hookProcessString("17"), wantSeconds: "17"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := runGeneratedHookProcess(t, hookProcessCase{
fixtures: []hookProcessFixture{{name: "timeout", body: hookProcessGNUTimeoutStub}},
timeout: tt.value,
})
if result.exitCode != 0 {
t.Fatalf("generated hook exit = %d, want 0\n%s", result.exitCode, result.output)
}
if marker := "duration=<" + tt.wantSeconds + ">"; !strings.Contains(result.output, marker) {
t.Errorf("output missing effective timeout %q\n%s", marker, result.output)
}
if got := strings.Contains(result.output, "invalid BEADS_HOOK_TIMEOUT"); got != tt.wantWarning {
t.Errorf("invalid-timeout warning presence = %v, want %v\n%s", got, tt.wantWarning, result.output)
}
})
}
}
func testHookProcessShellOptions(t *testing.T) {
const wantExit = 37
args := []string{"argument with space", "second-argument"}
for _, option := range []string{"", "e", "u", "eu"} {
name := option
if name == "" {
name = "default"
}
t.Run("set-"+name, func(t *testing.T) {
result := runGeneratedHookProcess(t, hookProcessCase{
fixtures: []hookProcessFixture{{name: "timeout", body: hookProcessGNUTimeoutStub}},
bdExit: wantExit,
shellOption: option,
args: args,
})
if result.exitCode != wantExit {
t.Fatalf("generated hook exit = %d, want bd exit %d\n%s", result.exitCode, wantExit, result.output)
}
assertHookProcessBDInvocation(t, result.output, "pre-push", args...)
})
}
}
func testHookProcessPOSIXShell(t *testing.T) {
const wantExit = 37
args := []string{"argument with space", "second-argument"}
result := runGeneratedHookProcess(t, hookProcessCase{
fixtures: []hookProcessFixture{{name: "timeout", body: hookProcessGNUTimeoutStub}},
bdExit: wantExit,
shellOption: "eu",
usePOSIXSh: true,
args: args,
})
if result.exitCode != wantExit {
t.Fatalf("generated hook exit = %d, want bd exit %d\n%s", result.exitCode, wantExit, result.output)
}
assertHookProcessBDInvocation(t, result.output, "pre-push", args...)
}
func testHookProcessReservedStatuses(t *testing.T) {
gnu := []hookProcessFixture{{name: "timeout", body: hookProcessGNUTimeoutStub}}
perl := []hookProcessFixture{
{name: "timeout", body: hookProcessIncompatibleTimeoutStub},
{name: "gtimeout", body: hookProcessIncompatibleGtimeoutStub},
{name: "perl", body: hookProcessPerlStub},
}
direct := perl[:2]
tests := []struct {
name string
fixtures []hookProcessFixture
bdExit int
wantExit int
wantWarning bool
wantDBWarning bool
}{
{name: "database-not-initialized is skipped", fixtures: gnu, bdExit: 3, wantDBWarning: true},
{name: "GNU owns 124", fixtures: gnu, bdExit: 124, wantWarning: true},
{name: "GNU preserves 137", fixtures: gnu, bdExit: 137, wantExit: 137},
{name: "GNU preserves 142", fixtures: gnu, bdExit: 142, wantExit: 142},
{name: "Perl preserves 124", fixtures: perl, bdExit: 124, wantExit: 124},
{name: "Perl owns 142", fixtures: perl, bdExit: 142, wantWarning: true},
{name: "direct preserves 124", fixtures: direct, bdExit: 124, wantExit: 124},
{name: "direct preserves 137", fixtures: direct, bdExit: 137, wantExit: 137},
{name: "direct preserves 142", fixtures: direct, bdExit: 142, wantExit: 142},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := runGeneratedHookProcess(t, hookProcessCase{fixtures: tt.fixtures, bdExit: tt.bdExit})
if result.exitCode != tt.wantExit {
t.Errorf("generated hook exit = %d, want %d\n%s", result.exitCode, tt.wantExit, result.output)
}
if got := strings.Contains(result.output, "timed out after"); got != tt.wantWarning {
t.Errorf("timeout warning presence = %v, want %v\n%s", got, tt.wantWarning, result.output)
}
if got := strings.Contains(result.output, "database not initialized"); got != tt.wantDBWarning {
t.Errorf("database warning presence = %v, want %v\n%s", got, tt.wantDBWarning, result.output)
}
})
}
}
func testHookProcessRealTimeoutExpiry(t *testing.T) {
helperName, helperDir := findHookProcessGNUTimeout(t)
timeout := "1"
result := runGeneratedHookProcess(t, hookProcessCase{
bdBody: hookProcessLongRunningBDStub,
timeout: &timeout,
pathTail: helperDir,
})
if result.exitCode != 0 {
t.Fatalf("generated hook exit = %d, want normalized timeout success\n%s", result.exitCode, result.output)
}
if !strings.Contains(result.output, "long-running-bd-started") || !strings.Contains(result.output, "timed out after 1s") {
t.Fatalf("%s did not expire and normalize the responsive child\n%s", helperName, result.output)
}
maxElapsed := 9 * time.Second
if result.elapsed > maxElapsed {
t.Errorf("%s expiry took %s, want at most %s", helperName, result.elapsed, maxElapsed)
}
}
func testHookProcessRealPerlExpiry(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Git for Windows Perl does not reliably preserve alarm across exec; GNU timeout is the normal Git Bash backend")
}
perlDir := findHookProcessPerl(t)
timeout := "1"
result := runGeneratedHookProcess(t, hookProcessCase{
fixtures: []hookProcessFixture{
{name: "timeout", body: hookProcessIncompatibleTimeoutStub},
{name: "gtimeout", body: hookProcessIncompatibleGtimeoutStub},
},
bdBody: hookProcessLongRunningBDStub,
timeout: &timeout,
pathTail: perlDir,
})
if result.exitCode != 0 {
t.Fatalf("generated hook exit = %d, want normalized Perl alarm success\n%s", result.exitCode, result.output)
}
if !strings.Contains(result.output, "long-running-bd-started") || !strings.Contains(result.output, "timed out after 1s") {
t.Fatalf("real Perl did not expire and normalize the responsive child\n%s", result.output)
}
if result.elapsed > 9*time.Second {
t.Errorf("Perl expiry took %s, want at most 9s", result.elapsed)
}
}
func testHookProcessWindowsSystemTimeoutCheckout(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("the System32 timeout boundary is Windows-specific")
}
systemRoot := os.Getenv("SystemRoot")
if systemRoot == "" {
t.Fatal("SystemRoot is required for the System32 timeout boundary")
}
system32 := filepath.Join(systemRoot, "System32")
systemTimeout := filepath.Join(system32, "timeout.exe")
if _, err := os.Stat(systemTimeout); err != nil {
t.Fatalf("System32 timeout.exe is required: %v", err)
}
binDir := t.TempDir()
writeHookProcessFixture(t, binDir, "bd", hookProcessBDStub)
controlledPath := hookProcessShellPath(t, binDir) + ":" + hookProcessShellPath(t, system32)
probe := exec.Command(hookProcessShell(t), "--noprofile", "--norc", "-c", `
PATH=$1
export PATH
_bd_test_timeout=$(command -v timeout) || exit 1
printf '%s\n' "$_bd_test_timeout"
if timeout --version >/dev/null 2>&1; then exit 2; fi
`, "system32-timeout-probe", controlledPath)
probe.Env = hookProcessEnv()
probeOutput, err := probe.CombinedOutput()
if err != nil {
t.Fatalf("exercise System32 timeout through the hook PATH: %v\n%s", err, string(probeOutput))
}
gotTimeout := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(string(probeOutput))), ".exe")
wantTimeout := strings.TrimSuffix(strings.ToLower(hookProcessShellPath(t, systemTimeout)), ".exe")
if gotTimeout != wantTimeout {
t.Fatalf("hook PATH resolved timeout to %q, want System32 %q", gotTimeout, wantTimeout)
}
repoDir := filepath.Join(t.TempDir(), "repo")
if err := os.MkdirAll(repoDir, 0o755); err != nil {
t.Fatalf("create temporary repository: %v", err)
}
gitPath, err := exec.LookPath("git.exe")
if err != nil {
t.Fatalf("Git for Windows is required: %v", err)
}
gitEnv := hookProcessGitEnv(t.TempDir(), "HOOK_TEST_BD_EXIT=0")
runHookProcessGit(t, gitPath, repoDir, gitEnv, "init", "--initial-branch=main", ".")
runHookProcessGit(t, gitPath, repoDir, gitEnv, "config", "core.hooksPath", ".git/hooks")
runHookProcessGit(t, gitPath, repoDir, gitEnv, "config", "user.name", "Hook Test")
runHookProcessGit(t, gitPath, repoDir, gitEnv, "config", "user.email", "hook-test@example.invalid")
if err := os.WriteFile(filepath.Join(repoDir, "tracked.txt"), []byte("initial\n"), 0o600); err != nil {
t.Fatalf("write tracked fixture: %v", err)
}
runHookProcessGit(t, gitPath, repoDir, gitEnv, "add", "--", "tracked.txt")
runHookProcessGit(t, gitPath, repoDir, gitEnv, "commit", "--no-verify", "-m", "initial")
commit := strings.TrimSpace(runHookProcessGit(t, gitPath, repoDir, gitEnv, "rev-parse", "HEAD"))
runHookProcessGit(t, gitPath, repoDir, gitEnv, "branch", "topic")
if head := strings.TrimSpace(runHookProcessGit(t, gitPath, repoDir, gitEnv, "symbolic-ref", "--short", "HEAD")); head != "main" {
t.Fatalf("HEAD = %q before checkout, want main", head)
}
hookBody := "#!/bin/sh\nPATH=" + hookProcessShellQuote(controlledPath) + "\nexport PATH\n" + generateHookSection("post-checkout")
writeHookProcessFixture(t, filepath.Join(repoDir, ".git", "hooks"), "post-checkout", hookBody)
checkout := exec.Command(gitPath, "checkout", "topic")
checkout.Dir = repoDir
checkout.Env = gitEnv
checkoutOutput, err := checkout.CombinedOutput()
if err != nil {
t.Fatalf("checkout reported generated-hook failure: %v\n%s", err, string(checkoutOutput))
}
output := string(checkoutOutput)
if !strings.Contains(output, "running without timeout") {
t.Errorf("checkout output missing incompatible-timeout fallback warning\n%s", output)
}
assertHookProcessBDInvocation(t, output, "post-checkout", commit, commit, "1")
if head := strings.TrimSpace(runHookProcessGit(t, gitPath, repoDir, gitEnv, "symbolic-ref", "--short", "HEAD")); head != "topic" {
t.Fatalf("HEAD = %q after successful checkout, want topic", head)
}
}
func runGeneratedHookProcess(t *testing.T, tc hookProcessCase) hookProcessResult {
t.Helper()
binDir := t.TempDir()
bdBody := tc.bdBody
if bdBody == "" {
bdBody = hookProcessBDStub
}
writeHookProcessFixture(t, binDir, "bd", bdBody)
for _, fixture := range tc.fixtures {
writeHookProcessFixture(t, binDir, fixture.name, fixture.body)
}
hookPath := filepath.Join(t.TempDir(), "pre-push")
writeHookProcessFixture(t, filepath.Dir(hookPath), filepath.Base(hookPath), "#!/bin/sh\n"+generateHookSection("pre-push"))
controlledPath := hookProcessShellPath(t, binDir)
if tc.pathTail != "" {
controlledPath += ":" + tc.pathTail
}
controlScript := `PATH=$1
export PATH
shift
case "$1" in
"") ;;
e) set -e ;;
u) set -u ;;
eu) set -eu ;;
*) exit 95 ;;
esac
shift
_bd_test_hook=$1
shift
. "$_bd_test_hook"
`
shellPath := hookProcessShell(t)
commandArgs := []string{"--noprofile", "--norc", "-c", controlScript, "generated-hook-test", controlledPath, tc.shellOption, hookProcessShellPath(t, hookPath)}
if tc.usePOSIXSh {
shellPath = hookProcessPOSIXShell(t)
commandArgs = []string{"-c", controlScript, "generated-hook-test", controlledPath, tc.shellOption, hookProcessShellPath(t, hookPath)}
}
commandArgs = append(commandArgs, tc.args...)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
t.Cleanup(cancel)
cmd := exec.CommandContext(ctx, shellPath, commandArgs...)
cmd.WaitDelay = time.Second
cmd.Env = append(hookProcessEnv(), "HOOK_TEST_BD_EXIT="+strconv.Itoa(tc.bdExit))
if tc.timeout != nil {
cmd.Env = append(cmd.Env, "BEADS_HOOK_TIMEOUT="+*tc.timeout)
}
started := time.Now()
output, err := cmd.CombinedOutput()
elapsed := time.Since(started)
if ctx.Err() != nil {
t.Fatalf("generated hook exceeded process-test deadline: %v\n%s", ctx.Err(), string(output))
}
exitCode := 0
if err != nil {
exitErr, ok := err.(*exec.ExitError)
if !ok {
t.Fatalf("run generated hook: %v\n%s", err, string(output))
}
exitCode = exitErr.ExitCode()
}
return hookProcessResult{output: string(output), exitCode: exitCode, elapsed: elapsed}
}
func findHookProcessGNUTimeout(t *testing.T) (string, string) {
t.Helper()
probe := `for _bd_test_candidate in timeout gtimeout; do
if command -v "$_bd_test_candidate" >/dev/null 2>&1 &&
_bd_test_version="$("$_bd_test_candidate" --version 2>/dev/null)"; then
case "$_bd_test_version" in
"timeout (GNU coreutils) "*)
_bd_test_path=$(command -v "$_bd_test_candidate")
printf '%s\n%s\n' "$_bd_test_candidate" "${_bd_test_path%/*}"
exit 0
;;
esac
fi
done
exit 1
`
cmd := exec.Command(hookProcessShell(t), "--noprofile", "--norc", "-c", probe)
cmd.Env = hookProcessEnv()
output, err := cmd.CombinedOutput()
if err != nil {
t.Skipf("compatible GNU timeout is unavailable: %s", strings.TrimSpace(string(output)))
}
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(lines) != 2 || lines[0] == "" || lines[1] == "" {
t.Fatalf("unexpected GNU timeout probe output: %q", string(output))
}
return lines[0], lines[1]
}
func findHookProcessPerl(t *testing.T) string {
t.Helper()
probe := `
_bd_test_path=$(command -v perl) || exit 1
perl -e 'exit 0' || exit 1
printf '%s\n' "${_bd_test_path%/*}"
`
cmd := exec.Command(hookProcessShell(t), "--noprofile", "--norc", "-c", probe)
cmd.Env = hookProcessEnv()
output, err := cmd.CombinedOutput()
if err != nil {
t.Skipf("Perl is unavailable: %s", strings.TrimSpace(string(output)))
}
dir := strings.TrimSpace(string(output))
if dir == "" {
t.Fatalf("unexpected Perl probe output: %q", string(output))
}
return dir
}
func writeHookProcessFixture(t *testing.T, dir, name, body string) {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
t.Fatalf("write %s fixture: %v", name, err)
}
if err := os.Chmod(path, 0o755); err != nil {
t.Fatalf("make %s fixture executable: %v", name, err)
}
}
func assertHookProcessBDInvocation(t *testing.T, output, hookName string, hookArgs ...string) {
t.Helper()
wantArgs := append([]string{"hooks", "run", hookName}, hookArgs...)
if marker := fmt.Sprintf("bd-argc=%d", len(wantArgs)); !strings.Contains(output, marker) {
t.Errorf("output missing %q\n%s", marker, output)
}
for index, arg := range wantArgs {
marker := fmt.Sprintf("bd-arg-%d=<%s>", index, arg)
if !strings.Contains(output, marker) {
t.Errorf("output missing argument marker %q\n%s", marker, output)
}
}
}
func hookProcessShell(t *testing.T) string {
t.Helper()
if runtime.GOOS == "windows" {
gitPath, err := exec.LookPath("git.exe")
if err != nil {
t.Fatalf("Git for Windows is required to locate Git Bash: %v", err)
}
dir := filepath.Dir(gitPath)
for range 5 {
for _, candidate := range []string{filepath.Join(dir, "bash.exe"), filepath.Join(dir, "bin", "bash.exe")} {
if info, statErr := os.Stat(candidate); statErr == nil && !info.IsDir() {
return candidate
}
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
t.Fatalf("could not locate Git Bash beside %s", gitPath)
}
shell, err := exec.LookPath("bash")
if err != nil {
t.Fatalf("Bash is required to exercise generated hooks: %v", err)
}
return shell
}
func hookProcessPOSIXShell(t *testing.T) string {
t.Helper()
if runtime.GOOS == "windows" {
bashPath := hookProcessShell(t)
gitRoot := filepath.Dir(filepath.Dir(bashPath))
candidate := filepath.Join(gitRoot, "usr", "bin", "sh.exe")
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate
}
t.Fatalf("could not locate Git for Windows POSIX sh beside %s", bashPath)
}
shell, err := exec.LookPath("sh")
if err != nil {
t.Fatalf("POSIX sh is required to exercise generated hooks: %v", err)
}
return shell
}
func hookProcessShellPath(t *testing.T, path string) string {
t.Helper()
absolute, err := filepath.Abs(path)
if err != nil {
t.Fatalf("resolve shell path %s: %v", path, err)
}
if runtime.GOOS != "windows" {
return filepath.ToSlash(absolute)
}
volume := filepath.VolumeName(absolute)
if len(volume) != 2 || volume[1] != ':' {
t.Fatalf("Git Bash fixture path must use a drive-letter volume: %q", absolute)
}
return "/" + strings.ToLower(volume[:1]) + filepath.ToSlash(absolute[len(volume):])
}
func hookProcessShellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
}
func hookProcessEnv() []string {
env := make([]string, 0, len(os.Environ())+2)
for _, entry := range os.Environ() {
key, _, ok := strings.Cut(entry, "=")
if !ok {
continue
}
upper := strings.ToUpper(key)
if upper == "BASH_ENV" || upper == "BASHOPTS" || upper == "ENV" || upper == "SHELLOPTS" ||
upper == "BEADS_HOOK_TIMEOUT" || upper == "HOOK_TEST_BD_EXIT" {
continue
}
env = append(env, entry)
}
return append(env, "BASH_ENV=", "ENV=")
}
func hookProcessGitEnv(home string, extra ...string) []string {
env := make([]string, 0, len(os.Environ())+8+len(extra))
for _, entry := range os.Environ() {
key, _, ok := strings.Cut(entry, "=")
if !ok {
continue
}
upper := strings.ToUpper(key)
if upper == "HOME" || upper == "USERPROFILE" || upper == "BASH_ENV" || upper == "BASHOPTS" || upper == "ENV" || upper == "SHELLOPTS" ||
strings.HasPrefix(upper, "GIT_") || upper == "HOOK_TEST_BD_EXIT" {
continue
}
env = append(env, entry)
}
env = append(env,
"HOME="+home,
"USERPROFILE="+home,
"BASH_ENV=",
"ENV=",
"GIT_CONFIG_NOSYSTEM=1",
"GIT_CONFIG_GLOBAL="+os.DevNull,
"GIT_CONFIG_SYSTEM="+os.DevNull,
"GIT_TERMINAL_PROMPT=0",
)
return append(env, extra...)
}
func runHookProcessGit(t *testing.T, gitPath, dir string, env []string, args ...string) string {
t.Helper()
cmd := exec.Command(gitPath, args...)
cmd.Dir = dir
cmd.Env = env
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, string(output))
}
return string(output)
}
func hookProcessString(value string) *string {
return &value
}
+66 -16
View File
@@ -199,31 +199,49 @@ func TestGenerateHookSection(t *testing.T) {
func TestGenerateHookSection_Timeout(t *testing.T) {
section := generateHookSection("pre-push")
// Must use shell timeout command with configurable duration
// The duration is configurable but must be validated before it reaches a
// helper command line.
if !strings.Contains(section, "BEADS_HOOK_TIMEOUT") {
t.Error("section missing BEADS_HOOK_TIMEOUT env var")
}
if !strings.Contains(section, fmt.Sprintf("%d", hookTimeoutSeconds)) {
t.Errorf("section missing default timeout %d", hookTimeoutSeconds)
}
if !strings.Contains(section, "command -v timeout") {
t.Error("section missing timeout availability check")
if !strings.Contains(section, `*[^0-9]*`) && !strings.Contains(section, `*[!0-9]*`) {
t.Error("section missing positive-integer timeout validation")
}
if !strings.Contains(section, "command -v gtimeout") {
t.Error("section missing gtimeout fallback for macOS coreutils")
}
if !strings.Contains(section, "perl -e 'alarm shift; exec @ARGV'") {
t.Error("section missing perl alarm fallback for stock macOS")
}
if !strings.Contains(section, "_bd_used_perl=1") {
t.Error("section missing perl branch marker")
if !strings.Contains(section, "invalid BEADS_HOOK_TIMEOUT") {
t.Error("section missing invalid-timeout warning")
}
// Timeout exit code (124) must be handled gracefully — continue, don't block git
if !strings.Contains(section, "_bd_exit -eq 124") {
// A name match is insufficient: native Windows has an incompatible
// timeout.exe. Require a successful GNU identity probe for timeout or
// gtimeout before invoking it.
if !strings.Contains(section, "for _bd_timeout_candidate in timeout gtimeout") {
t.Error("section missing ordered timeout/gtimeout capability probes")
}
if !strings.Contains(section, `if _bd_timeout_version="$("$_bd_timeout_candidate" --version 2>/dev/null)"; then`) {
t.Error("section does not require a successful version probe")
}
if !strings.Contains(section, `"timeout (GNU coreutils) "*`) {
t.Error("section missing GNU coreutils identity check")
}
if !strings.Contains(section, `"$_bd_timeout_command" -- "$_bd_timeout"`) {
t.Error("section missing GNU timeout argv separator")
}
if !strings.Contains(section, "perl -e 'alarm shift; exec @ARGV' --") {
t.Error("section missing perl alarm fallback for stock macOS")
}
if !strings.Contains(section, "_bd_timeout_backend=perl") {
t.Error("section missing scoped perl backend marker")
}
// GNU deadline statuses and Perl SIGALRM are scoped to the backend that can
// synthesize them. The direct fallback must not swallow a natural 124/142.
if !strings.Contains(section, `"$_bd_exit" -eq 124`) {
t.Error("section missing timeout exit code handling")
}
if !strings.Contains(section, "[ $_bd_used_perl -eq 1 ] && [ $_bd_exit -eq 142 ]") {
if !strings.Contains(section, `"$_bd_timeout_backend" = perl`) || !strings.Contains(section, `"$_bd_exit" -eq 142`) {
t.Error("section missing perl-scoped SIGALRM timeout exit code handling")
}
if !strings.Contains(section, "timed out") {
@@ -236,12 +254,44 @@ func TestGenerateHookSection_Timeout(t *testing.T) {
}
}
func TestTrackedManagedHookSectionsMatchGenerator(t *testing.T) {
for _, hookName := range managedHookNames {
hookName := hookName
t.Run(hookName, func(t *testing.T) {
path := filepath.Join("..", "..", ".githooks", hookName)
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read tracked hook %s: %v", path, err)
}
tracked := string(content)
begin := strings.Index(tracked, hookSectionBeginLine())
if begin < 0 {
t.Fatalf("tracked hook missing %q", hookSectionBeginLine())
}
endMarker := hookSectionEndLine() + "\n"
relativeEnd := strings.Index(tracked[begin:], endMarker)
if relativeEnd < 0 {
t.Fatalf("tracked hook missing %q", hookSectionEndLine())
}
end := begin + relativeEnd + len(endMarker)
if got, want := tracked[begin:end], generateHookSection(hookName); got != want {
t.Fatalf("tracked managed section drifted from generator\nwant:\n%s\ngot:\n%s", want, got)
}
if strings.Count(tracked, hookSectionBeginPrefix) != 1 || strings.Count(tracked, hookSectionEndPrefix) != 1 {
t.Fatal("tracked hook must contain exactly one managed section")
}
})
}
}
// TestGenerateHookSection_DBNotInitialized verifies exit code 3 handling (GH#2449).
func TestGenerateHookSection_DBNotInitialized(t *testing.T) {
section := generateHookSection("pre-commit")
// Exit code 3 = beads database not initialized; hook must continue gracefully
if !strings.Contains(section, "_bd_exit -eq 3") {
if !strings.Contains(section, `"$_bd_exit" -eq 3`) {
t.Error("section missing exit code 3 (DB not initialized) handling")
}
if !strings.Contains(section, "database not initialized") {
@@ -250,7 +300,7 @@ func TestGenerateHookSection_DBNotInitialized(t *testing.T) {
// After handling exit code 3, the effective exit must be 0 (success)
// Verify the pattern: set _bd_exit=0 after detecting code 3
if !strings.Contains(section, "if [ $_bd_exit -eq 3 ]; then") {
if !strings.Contains(section, `if [ "$_bd_exit" -eq 3 ]; then`) {
t.Error("section missing exit code 3 conditional")
}
}
+27 -6
View File
@@ -42,8 +42,10 @@ install or refresh them manually:
bd hooks install
```
Installed hooks are thin shims that call `bd hooks run <hook-name>`, so
upgrading `bd` automatically updates hook behavior:
Installed hooks are thin shims that call `bd hooks run <hook-name>`. Upgrading
`bd` automatically updates the delegated behavior inside that command; the
shim's generated shell policy remains installed content and changes only when
the hook is installed or refreshed:
| Hook | What it does |
|------|--------------|
@@ -126,10 +128,18 @@ hooks {
### Hook Timeout
The hook shim wraps `bd hooks run` with an OS-level `timeout` so hooks cannot
hang git operations indefinitely. The default is **300 seconds** (5 minutes),
which accommodates chained pre-commit pipelines (eslint, prettier, TypeScript
compilation). Override it with the `BEADS_HOOK_TIMEOUT` environment variable:
The hook shim applies a soft deadline to `bd hooks run` when a compatible
helper is available. It
uses `timeout` or `gtimeout` only after a successful GNU coreutils identity
probe, avoiding the incompatible `timeout.exe` that native Windows can place
on `PATH`. GNU timeout sends `TERM` at the configured deadline. On POSIX hosts,
the Perl fallback uses `SIGALRM` on the direct `bd` process at the deadline.
Git for Windows Perl does not guarantee that alarm across `exec`, so GNU
coreutils is the preferred deadline backend there.
The default deadline is **300 seconds** (5 minutes), which accommodates chained
pre-commit pipelines (eslint, prettier, TypeScript compilation). Override it
with the `BEADS_HOOK_TIMEOUT` environment variable:
```bash
# Set a longer timeout (in seconds)
@@ -139,6 +149,17 @@ export BEADS_HOOK_TIMEOUT=600 # 10 minutes
BEADS_HOOK_TIMEOUT=600 git commit -m "..."
```
The value must be a positive whole number of seconds. Invalid values and zero
produce a warning and use the 300-second default. These are soft process
deadlines, not process-tree containment: TERM-resistant work or descendants
can outlive them. If neither GNU timeout nor Perl is available, the hook warns
and runs directly without a deadline; that last-resort path can hang until the
hook itself returns.
After upgrading from a release whose generated hooks used a name-only timeout
check, run `bd hooks install` once to refresh already-installed canonical hook
sections. Automatic generated-policy adoption is tracked separately.
When the timeout is reached, beads prints a warning and lets the git
operation proceed — the commit or push is not blocked.
+13
View File
@@ -464,6 +464,19 @@ covers both beads' own work and your entire hook pipeline.
export BEADS_HOOK_TIMEOUT=600 # 10 minutes (in seconds)
```
The value must be a positive whole number of seconds. Invalid values and zero
warn and fall back to 300 seconds. Beads accepts `timeout` or `gtimeout` only
when a successful version probe identifies GNU coreutils; native Windows
`timeout.exe` is not compatible. If neither GNU timeout nor Perl is available,
the hook warns that it is running directly without a deadline.
GNU timeout sends `TERM`; on POSIX hosts, Perl's alarm applies to the direct
`bd` process. Git for Windows Perl does not guarantee that alarm across
`exec`, so GNU coreutils is preferred there. TERM-resistant work and
descendant processes are not guaranteed to stop. After upgrading from a
version with the name-only timeout check, run `bd hooks install` once to
refresh existing canonical sections.
### Permission denied on git hooks
Git hooks need execute permissions:
+34 -2
View File
@@ -165,6 +165,38 @@ func TestPRCIGateRequiresJSWasmHookExecution(t *testing.T) {
}
}
func TestPRCIGateRequiresGeneratedHookTimeoutProcessBoundary(t *testing.T) {
const (
jobName = "pr-preflight-platforms"
stepName = "Exercise generated Git hook timeout process boundary"
stepCommand = "go test '-tags=gms_pure_go' -count=1 -run '^TestGeneratedHookTimeoutProcessBoundary$' ./cmd/bd"
gateKey = "PR_PREFLIGHT_PLATFORMS"
)
workflow := readCIWorkflow(t, "pr.yml")
job := workflow.job(t, jobName)
if job.RunsOn != "${{ matrix.os }}" || !equalStrings(job.Strategy.Matrix.OS, []string{"ubuntu-latest", "macos-latest", "windows-latest"}) {
t.Errorf("generated-hook process job is not the required three-host matrix: runs-on=%q os=%v", job.RunsOn, job.Strategy.Matrix.OS)
}
if job.TimeoutMinutes != 20 {
t.Errorf("generated-hook process job timeout = %d minutes, want 20", job.TimeoutMinutes)
}
step := job.step(t, stepName)
if step.If != "" || (step.ContinueOnError != nil && step.ContinueOnError != false) || step.Shell != "bash" || step.Run != stepCommand {
t.Errorf("generated-hook process step is not required exact Bash execution: if=%q continue-on-error=%v shell=%q run=%q",
step.If, step.ContinueOnError, step.Shell, step.Run)
}
assertStepsBefore(t, job, []string{"Restore Go module cache"}, []string{stepName})
gate := workflow.job(t, "ci-gate")
gateEnv := gate.step(t, "Evaluate CI gate").Env
if !contains(gate.Needs, jobName) || gateEnv[gateKey] != "${{ needs.pr-preflight-platforms.result }}" ||
!contains(strings.Fields(gateEnv["CI_GATE_REQUIRED"]), gateKey) {
t.Errorf("ci-gate does not require the three-host generated-hook lane: needs=%v %s=%q required=%q",
gate.Needs, gateKey, gateEnv[gateKey], gateEnv["CI_GATE_REQUIRED"])
}
}
func TestStorageDomainUOWJobsUseNestedTimeoutBudgets(t *testing.T) {
const (
storageTimeoutMinutes = 15
@@ -273,8 +305,8 @@ func TestPRPreflightPlatformsRunsTestScriptPrebuiltBinaryContract(t *testing.T)
if job.RunsOn != "${{ matrix.os }}" {
t.Errorf("pr-preflight-platforms runs-on = %q, want matrix.os", job.RunsOn)
}
if job.If != "" || job.TimeoutMinutes != 10 {
t.Errorf("pr-preflight-platforms condition/timeout = %q/%d, want unconditional/10",
if job.If != "" || job.TimeoutMinutes != 20 {
t.Errorf("pr-preflight-platforms condition/timeout = %q/%d, want unconditional/20",
job.If, job.TimeoutMinutes)
}
if got := job.Strategy.Matrix.OS; !equalStrings(got, []string{"ubuntu-latest", "macos-latest", "windows-latest"}) {