mirror of
https://github.com/gastownhall/beads.git
synced 2026-09-14 20:17:24 +08:00
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:
+41
-15
@@ -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
@@ -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
@@ -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
@@ -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 ---
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"}) {
|
||||
|
||||
Reference in New Issue
Block a user