mirror of
https://github.com/Donchitos/Claude-Code-Game-Studios.git
synced 2026-10-16 01:37:05 +00:00
Fix release for 1.1.2: the 14 longest skills keep their later phases after a compaction, a question nobody answered is never taken as an answer, settings read the same from a subfolder, the commit check works for a project in a subfolder of a larger repository, every skill opens with a title, and the shipped allow rules no longer approve deleting a branch or writing files without asking. Details in CHANGELOG.md; upgrade steps in UPGRADING.md (v1.1.2 → v1.1.3). Refs #83 Refs #133 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0185en4t76fPn7315q4c3nko
178 lines
10 KiB
Bash
178 lines
10 KiB
Bash
#!/bin/bash
|
|
|
|
# --- work from the project root ----------------------------------------------
|
|
# Every path below is repo-relative, so a hook invoked with a working directory
|
|
# that is not the repo root would silently read and write the WRONG TREE --
|
|
# returning a near-empty result instead of the session-recovery block, and
|
|
# creating stray trees such as docs/production/session-logs/ on write.
|
|
#
|
|
# PRECEDENCE IS LOAD-BEARING. A cwd that IS a project root carries real
|
|
# information and must win: a caller sitting inside another project means that
|
|
# project, not this one. Resolving to the script's own location first would
|
|
# override them. So, in order:
|
|
# 1. cwd holds project.yaml -> cwd (a project root)
|
|
# 2. cwd holds .claude/ -> cwd (a project root not yet configured)
|
|
# 3. CLAUDE_PROJECT_DIR -> that (populated in the hook environment)
|
|
# 4. this script's location -> <root>/.claude/hooks/../.. by construction
|
|
# Rule 4 always works and needs no environment at all; rules 1-2 stop it from
|
|
# overriding a caller that legitimately means somewhere else.
|
|
#
|
|
# NOT an upward search: that resolves a nested project to its parent's config.
|
|
if [ -f "project.yaml" ] || [ -d ".claude" ]; then
|
|
CCGS_ROOT="$PWD"
|
|
elif [ -n "${CLAUDE_PROJECT_DIR:-}" ] && [ -d "${CLAUDE_PROJECT_DIR}" ]; then
|
|
CCGS_ROOT="$CLAUDE_PROJECT_DIR"
|
|
else
|
|
CCGS_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)"
|
|
fi
|
|
[ -n "$CCGS_ROOT" ] && cd "$CCGS_ROOT" 2>/dev/null || true
|
|
|
|
# Claude Code PreToolUse hook: Validates git push commands
|
|
# Warns on pushes to protected branches -- as hook JSON on stdout, the only
|
|
# channel the user or Claude sees on exit 0.
|
|
# Exit 0 = allow (the normal permission flow still applies), Exit 2 = block
|
|
#
|
|
# Input schema (PreToolUse for Bash):
|
|
# { "tool_name": "Bash", "tool_input": { "command": "git push origin main" } }
|
|
|
|
INPUT=$(cat)
|
|
|
|
# Most Bash and PowerShell calls do not mention git at all. Leave before any
|
|
# parsing: this hook runs on every one of them, and the parse is most of its
|
|
# cost (the early exit is the shell start-up alone).
|
|
# Tested only after the "tool_input" key: matching the whole payload also
|
|
# tests cwd and transcript_path, so a project path containing "GitHub" or
|
|
# "digital" never took this exit and paid the full parse on every call.
|
|
case "$INPUT" in *'"tool_input"'*[Gg][Ii][Tt]*) ;; *) exit 0 ;; esac
|
|
|
|
# Parse command -- use jq if available, fall back to grep. The fallback's string
|
|
# body is (\\.|[^"\\])* so an escaped quote does not end it early.
|
|
if command -v jq >/dev/null 2>&1; then
|
|
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
|
|
else
|
|
# LC_ALL=C: in a UTF-8 locale an emoji in the command makes this grep match
|
|
# nothing, and an empty COMMAND skips the check below.
|
|
COMMAND=$(echo "$INPUT" | LC_ALL=C grep -oE '"command"[[:space:]]*:[[:space:]]*"(\\.|[^"\\])*"' | head -1 | LC_ALL=C sed 's/"command"[[:space:]]*:[[:space:]]*"//;s/"$//')
|
|
# Decode the JSON escapes once, here, and never jq's output -- \\ first,
|
|
# through a placeholder. validate-commit.sh explains each escape.
|
|
COMMAND=$(printf '%s\n' "$COMMAND" \
|
|
| LC_ALL=C awk '{ gsub(/\\\\/, "\002"); gsub(/\\r/, ""); gsub(/\\t/, "\t"); gsub(/\\n/, "\n"); gsub(/\\"/, "\""); print }' \
|
|
| tr '\002' '\\')
|
|
fi
|
|
|
|
# Print the first segment of $2 that runs `git $1`, anywhere in the command.
|
|
# An ^git anchor missed every chained push (`git add . && git push`).
|
|
# Kept identical in validate-commit.sh, which explains the splitting.
|
|
git_segment() {
|
|
printf '%s\n' "$2" \
|
|
| awk '{ while ($0 ~ /\\$/) { line = $0; sub(/\\$/, "", line); if ((getline nxt) > 0) { $0 = line nxt } else { $0 = line; break } } print }' \
|
|
| awk '
|
|
pd != "" { t = $0; sub(/\r$/, "", t); if (t ~ /^'"'"'@/) pd = ""; next }
|
|
d != "" { t = $0; sub(/\r$/, "", t); if (tabs) sub(/^\t+/, "", t); if (t == d) d = ""; next }
|
|
{ print }
|
|
match($0, /<<-?[ \t]*("[^"]*"|'"'"'[^'"'"']*'"'"'|\\?[A-Za-z_][^ \t\r;&|()<>"'"'"'\\]*)/) && substr($0, RSTART - 1, 1) != "<" \
|
|
&& substr($0, 1, RSTART - 1) !~ /\$\(\([^)]*$/ \
|
|
&& $0 !~ /(^|[^A-Za-z0-9_.-])(bash|sh|zsh|dash|ksh|fish|pwsh|powershell|python[0-9.]*|py|node|ruby|perl|php|eval|source)([ \t]|$)/ {
|
|
t = substr($0, RSTART, RLENGTH); q = ""
|
|
for (i = 1; i < RSTART; i++) {
|
|
c = substr($0, i, 1)
|
|
if (q == "") { if (c == "\"" || c == "'"'"'") q = c }
|
|
else if (c == q || (q == "\"" && c == "$" && substr($0, i + 1, 1) == "(")) q = ""
|
|
}
|
|
if (q == "") { d = t; tabs = (d ~ /^<<-/); sub(/^<<-?[ \t]*/, "", d); gsub(/[\\"'"'"']/, "", d) }
|
|
}
|
|
# A PowerShell single-quote here-string body (opened by @ then a
|
|
# single quote, closed by a single quote then @) is literal text,
|
|
# the same as a bash here-doc -- UNLESS it is the double-quote form,
|
|
# which PowerShell itself expands $(...) inside, so that form is
|
|
# left unmarked and flows through unchanged.
|
|
$0 ~ /@'"'"'[ \t]*\r?$/ { pd = "x" }' \
|
|
| awk '{
|
|
s = $0; out = ""
|
|
gsub(/"([^"]*[\/\\])?[Gg][Ii][Tt](\.exe)?"/, "git", s); gsub(/'"'"'([^'"'"']*[\/\\])?[Gg][Ii][Tt](\.exe)?'"'"'/, "git", s)
|
|
gsub(/\$\([^ \t;&|()`"'"'"']*\)|`[^ \t;&|()`"'"'"']*`/, "$_", s)
|
|
while (match(s, /(-[Cc]|--(git-dir|work-tree|namespace|super-prefix|config-env|attr-source))[ \t]+("[^"]*"|'"'"'[^'"'"']*'"'"')|=("[^"]*"|'"'"'[^'"'"']*'"'"')/)) {
|
|
p = out substr(s, 1, RSTART - 1); a = substr(s, RSTART, RLENGTH); s = substr(s, RSTART + RLENGTH)
|
|
if (a ~ /^=/) {
|
|
f = "="; ok = p ~ /(^|[ \t;&|(){`"'"'"'])([^ \t;&|(){`"'"'"']*[\/\\])?[Gg][Ii][Tt](\.exe)?([ \t]+[^ \t;&|(){`"'"'"']+)+$/
|
|
} else {
|
|
f = a; sub(/[ \t]+["'"'"'].*$/, " ", f)
|
|
ok = p ~ /(^|[ \t;&|(){`"'"'"'])([^ \t;&|(){`"'"'"']*[\/\\])?[Gg][Ii][Tt](\.exe)?([ \t]+[^ \t;&|(){`"'"'"']+)*[ \t]+$/
|
|
}
|
|
if (!ok) { out = p substr(a, 1, 1); s = substr(a, 2) s; continue }
|
|
v = a; sub(/^[^"'"'"']*["'"'"']/, "", v); v = substr(v, 1, length(v) - 1); gsub(/[ \t;&|()`]/, "\001", v)
|
|
out = p f v
|
|
}
|
|
s = out s
|
|
# A quoted WORD with nothing inside it a shell would split on (a
|
|
# quoted branch name, a quoted refspec) is unquoted the same way
|
|
# rather than read as a delimiter -- a bare `"` or `'"'"'` used to
|
|
# split the line here, dropping the quoted text (and everything
|
|
# after it on that line) from what the classifier below ever saw.
|
|
# A quoted string that DOES contain such characters is left alone:
|
|
# it may be a whole command (`bash -c "git commit"`) meant to fall
|
|
# on its own line for re-inspection, or a commit message, and either
|
|
# reading is unchanged from before.
|
|
out = ""
|
|
while (match(s, /"[^" \t;&|()`]*"|'"'"'[^'"'"' \t;&|()`]*'"'"'/)) {
|
|
p = out substr(s, 1, RSTART - 1); a = substr(s, RSTART, RLENGTH); s = substr(s, RSTART + RLENGTH)
|
|
out = p substr(a, 2, length(a) - 2)
|
|
}
|
|
s = out s
|
|
gsub(/&&|\|\|/, "\n", s); gsub(/[;&|()`"'"'"']/, "\n", s); print s
|
|
}' \
|
|
| grep -E "^[[:space:]]*((if|then|else|elif|do|while|until|time|exec|command|nohup|!|[{])[[:space:]]+|(env|sudo|doas|xargs|nice|timeout|stdbuf|winpty|command)([[:space:]]+-[A-Za-z0-9-]+(=[^[:space:]]*)?([[:space:]]+[^-[:space:]][^[:space:]]*)?)*[[:space:]]+([0-9][0-9.]*[smhd]?[[:space:]]+)?|[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+)*([^[:space:]]*[/\\])?[Gg][Ii][Tt](\.exe)?([[:space:]]+(-[Cc][[:space:]]+[^[:space:]]+|--(git-dir|work-tree|namespace|super-prefix|config-env|attr-source)[[:space:]]+[^[:space:]]+|-[A-Za-z]|--[A-Za-z-]+(=[^[:space:]]+)?))*[[:space:]]+$1([[:space:]]|\$)"
|
|
}
|
|
|
|
# Only process git push commands
|
|
PUSH_SEG=$(git_segment push "$COMMAND") || exit 0
|
|
|
|
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
|
|
MATCHED_BRANCH=""
|
|
|
|
# Check if pushing to a protected branch: main, master, or any release/*
|
|
# branch (the trunk model's only other protected line -- no GitFlow develop).
|
|
for branch in main master; do
|
|
if [ "$CURRENT_BRANCH" = "$branch" ]; then
|
|
MATCHED_BRANCH="$branch"
|
|
break
|
|
fi
|
|
# Also check if pushing to a protected branch explicitly: as its own word,
|
|
# or as a refspec's destination (HEAD:main, feature:main, +main,
|
|
# refs/heads/main). `main:feature` pushes TO feature and stays quiet.
|
|
if echo "$PUSH_SEG" | grep -qE "([[:space:]+:]|refs/heads/)${branch}([[:space:]]|$)"; then
|
|
MATCHED_BRANCH="$branch"
|
|
break
|
|
fi
|
|
done
|
|
|
|
# release/* is a name pattern, not a fixed branch, so it needs its own checks:
|
|
# pushing FROM a release/* branch (a bare `git push`), or explicitly TO one.
|
|
if [ -z "$MATCHED_BRANCH" ]; then
|
|
case "$CURRENT_BRANCH" in
|
|
release/*) MATCHED_BRANCH="$CURRENT_BRANCH" ;;
|
|
esac
|
|
fi
|
|
if [ -z "$MATCHED_BRANCH" ] && echo "$PUSH_SEG" | grep -qE "([[:space:]+:]|refs/heads/)release/[^[:space:]:]+([[:space:]]|$)"; then
|
|
MATCHED_BRANCH=$(echo "$PUSH_SEG" | grep -oE "release/[^[:space:]:]+" | head -1)
|
|
fi
|
|
|
|
if [ -n "$MATCHED_BRANCH" ]; then
|
|
# Allow the push, but warn where it is seen. stderr on exit 0 goes to the
|
|
# debug log only, so this warning reached neither the user nor Claude.
|
|
# systemMessage is shown to the user; additionalContext reaches Claude next
|
|
# to the tool result. No permissionDecision: "allow" would skip the user's
|
|
# permission prompt. MATCHED_BRANCH is no longer a fixed name -- for
|
|
# release/* it is the current branch or text taken from the command, and
|
|
# git allows a literal `"` or `\` in a branch name, so it must be escaped
|
|
# before it goes into the JSON string below.
|
|
SAFE_BRANCH=$(printf '%s' "$MATCHED_BRANCH" | sed 's/\\/\\\\/g; s/"/\\"/g')
|
|
MSG="Push to protected branch '$SAFE_BRANCH' detected. Reminder: Ensure build passes, unit tests pass, and no open bugs the release gate blocks (S1; S1-S3 at workflow: full)."
|
|
printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"%s"}}\n' "$MSG" "$MSG"
|
|
# To block instead, replace the printf above with:
|
|
# echo "BLOCKED: Run tests before pushing to $MATCHED_BRANCH" >&2
|
|
# exit 2
|
|
fi
|
|
|
|
exit 0
|