Release 1.1.2 — the default path works end to end

Fix release for 1.1.1: the minimal path end to end, engine commands that
run on Godot, Unity and Unreal, hook warnings you can see, and verdicts
that say when they could not check. Details in CHANGELOG.md; upgrade
steps in UPGRADING.md (v1.1.1 → v1.1.2).

Fixes #67
Fixes #131

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WuJn2FdynEybfL5KC697nJ
This commit is contained in:
Donchitos
2026-09-29 21:32:19 +10:00
co-authored by Claude Opus 5.5
parent 7ed2c3e9c4
commit b21fa0f7f2
383 changed files with 22888 additions and 10618 deletions
+117 -15
View File
@@ -28,47 +28,149 @@ 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
# Exit 0 = allow, Exit 2 = block
# 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)
# Parse command -- use jq if available, fall back to grep
# 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 (~350 ms a call measured; 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
COMMAND=$(echo "$INPUT" | grep -oE '"command"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/"command"[[:space:]]*:[[:space:]]*"//;s/"$//')
# 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
if ! echo "$COMMAND" | grep -qE '^git[[:space:]]+push'; then
exit 0
fi
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
for branch in develop main master; do
# 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 (quote branch name for safety)
if echo "$COMMAND" | grep -qE "[[:space:]]${branch}([[:space:]]|$)"; then
# 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
echo "Push to protected branch '$MATCHED_BRANCH' detected." >&2
echo "Reminder: Ensure build passes, unit tests pass, and no S1/S2 bugs exist." >&2
# Allow the push but warn -- uncomment below to block instead:
# echo "BLOCKED: Run tests before pushing to $CURRENT_BRANCH" >&2
# 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