mirror of
https://github.com/glittercowboy/get-shit-done
synced 2026-05-13 18:46:38 +02:00
The three opt-in bash hooks (gsd-phase-boundary.sh, gsd-session-state.sh,
gsd-validate-commit.sh) shipped with #!/bin/bash, which fails on distros
that don't ship bash at /bin/bash (NixOS, minimal Alpine images, some
container runtimes). POSIX guarantees /bin/sh but not /bin/bash.
This is latent in the default install path because Claude Code wires the
hooks as `bash <path>` from settings.json (PATH-resolved — the script's
own shebang is read as a comment by bash). The fix matters when scripts
are run directly: tests, future installer changes, or manual debugging.
Changes:
- hooks/gsd-{phase-boundary,session-state,validate-commit}.sh: shebang
switched to #!/usr/bin/env bash, matching the convention already used
in scripts/*.sh.
- tests/bug-2136-sh-hook-version.test.cjs: assertion updated to expect
the new shebang; comment updated to spell out the rationale.
- tests/bug-2979-hook-absolute-node.test.cjs: doc-comment updated — the
prior wording cited "POSIX std PATH always has /bin" as the reason
bare `bash` is OK. The actual reason is that bare `bash` is
PATH-resolved, which is portable across distros that don't ship
/bin/bash. POSIX std PATH guarantees /bin/sh, not /bin/bash.
- bin/install.js::buildHookCommand: comment block clarifying the same.
No behavior change in this file — bare `bash` was already correct.
- .changeset/portable-bash-shebang-hooks.md: changeset entry.
Verified locally on NixOS:
- npm run build:hooks: hooks/dist/*.sh shebangs propagate correctly.
- node --test tests/bug-2136-*.cjs tests/bug-2979-*.cjs
tests/bug-1817-*.cjs tests/bug-1834-*.cjs tests/bug-1906-*.cjs
tests/bug-2557-*.cjs tests/bug-3017-*.cjs tests/security-scan.test.cjs
tests/hooks-doc-parity.test.cjs: 126/126 pass.
- node scripts/run-tests.cjs (full suite): 6944 pass / 0 fail / 5 skip.
58 lines
2.7 KiB
Bash
Executable File
58 lines
2.7 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# gsd-hook-version: {{GSD_VERSION}}
|
|
# gsd-validate-commit.sh — PreToolUse hook: enforce Conventional Commits format
|
|
# Blocks git commit commands with non-conforming messages (exit 2).
|
|
# Allows conforming messages and all non-commit commands (exit 0).
|
|
# Uses Node.js for JSON parsing (always available in GSD projects, no jq dependency).
|
|
#
|
|
# OPT-IN: This hook is a no-op unless config.json has hooks.community: true.
|
|
# Enable with: "hooks": { "community": true } in .planning/config.json
|
|
|
|
# Check opt-in config — exit silently if not enabled
|
|
if [ -f .planning/config.json ]; then
|
|
ENABLED=$(node -e "try{const c=require('./.planning/config.json');process.stdout.write(c.hooks?.community===true?'1':'0')}catch{process.stdout.write('0')}" 2>/dev/null)
|
|
if [ "$ENABLED" != "1" ]; then exit 0; fi
|
|
else
|
|
exit 0
|
|
fi
|
|
|
|
INPUT=$(cat)
|
|
|
|
# Extract command from JSON using Node (handles escaping correctly, no jq needed)
|
|
CMD=$(echo "$INPUT" | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{process.stdout.write(JSON.parse(d).tool_input?.command||'')}catch{}})" 2>/dev/null)
|
|
|
|
# Only check git commit commands.
|
|
# Delegates to hooks/lib/git-cmd.js isGitSubcommand() — the canonical token-walk
|
|
# classifier that handles env-prefix, -C path, and full-path git invocations.
|
|
# A naive `^git\s+commit` regex misses all three; this guard fixes that (#3129).
|
|
HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
if GIT_CMD_LIB="$HOOK_DIR/lib/git-cmd.js" node -e "
|
|
const {isGitSubcommand}=require(process.env.GIT_CMD_LIB);
|
|
process.exit(isGitSubcommand(process.argv[1],'commit')?0:1);
|
|
" "$CMD" 2>/dev/null; then
|
|
# Extract message from -m flag
|
|
MSG=""
|
|
if [[ "$CMD" =~ -m[[:space:]]+\"([^\"]+)\" ]]; then
|
|
MSG="${BASH_REMATCH[1]}"
|
|
elif [[ "$CMD" =~ -m[[:space:]]+\'([^\']+)\' ]]; then
|
|
MSG="${BASH_REMATCH[1]}"
|
|
fi
|
|
|
|
if [ -n "$MSG" ]; then
|
|
SUBJECT=$(echo "$MSG" | head -1)
|
|
# Validate Conventional Commits format
|
|
if ! [[ "$SUBJECT" =~ ^(feat|fix|docs|style|refactor|perf|test|build|ci|chore)(\(.+\))?:[[:space:]].+ ]]; then
|
|
# Emit a typed `code` field alongside `reason` (#2974). Tests assert
|
|
# on the stable code string; the reason is the human-readable copy.
|
|
echo '{"decision": "block", "code": "CONVENTIONAL_COMMITS_VIOLATION", "reason": "Commit message must follow Conventional Commits: <type>(<scope>): <subject>. Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore. Subject must be <=72 chars, lowercase, imperative mood, no trailing period."}'
|
|
exit 2
|
|
fi
|
|
if [ ${#SUBJECT} -gt 72 ]; then
|
|
echo '{"decision": "block", "code": "COMMIT_SUBJECT_TOO_LONG", "reason": "Commit subject must be 72 characters or less."}'
|
|
exit 2
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
exit 0
|