Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 0 additions & 90 deletions .claude/commands/sql-diagram.md

This file was deleted.

79 changes: 79 additions & 0 deletions .claude/hooks/pr-merge-description.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
#!/bin/bash
# PreToolUse hook: update PR description and inject it as the merge commit message
set -euo pipefail

INPUT=$(cat)

# Use Python for all JSON parsing and output (jq has snap confinement issues in this env)
python3 - "$INPUT" <<'PYEOF'
import json, sys, os, re, subprocess, tempfile, shlex

raw = sys.argv[1]
try:
data = json.loads(raw)
except json.JSONDecodeError:
sys.exit(0)

command = data.get("tool_input", {}).get("command", "")

# Self-gate on the command text rather than trusting the settings.json `if:` filter.
# Without this the hook runs on EVERY Bash call: it appends --subject/--body-file to
# unrelated commands (breaking them) and fires a `gh pr edit` network write each time.
# Observed in practice, which is why the check is here and not only in settings.json.
if not re.search(r'\bgh\s+pr\s+merge\b', command):
sys.exit(0)

# Already has explicit body/subject — don't override user's intent
if any(f in command for f in ("--body", "--subject", "--body-file")):
sys.exit(0)

# Rebase: individual commits keep their own messages
if "--rebase" in command:
sys.exit(0)

# Extract PR ref (number, URL, or branch) — first non-flag token after 'gh pr merge'
m = re.search(r'gh pr merge\s+([^\s-]\S*)', command)
pr_arg = m.group(1) if m else None

# Fetch PR metadata
try:
cmd = ["gh", "pr", "view", "--json", "number,title,body"]
if pr_arg:
cmd = ["gh", "pr", "view", pr_arg, "--json", "number,title,body"]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
if result.returncode != 0:
sys.exit(0)
pr = json.loads(result.stdout)
except Exception:
sys.exit(0)

number = str(pr["number"])
title = pr["title"]
body = pr.get("body") or ""

# Write body to temp file — avoids quoting/newline issues
with tempfile.NamedTemporaryFile(mode="w", suffix=".md", prefix="pr-body-",
dir="/tmp", delete=False) as f:
f.write(body)
tmpfile = f.name

# Update the PR description on GitHub
try:
subprocess.run(
["gh", "pr", "edit", number, "--title", title, "--body-file", tmpfile],
capture_output=True, timeout=15
)
except Exception:
pass # non-fatal

# Build updated command with --subject and --body-file
new_cmd = command + " --subject " + shlex.quote(title) + " --body-file " + tmpfile

# Return updated input to the harness
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"updatedInput": {"command": new_cmd}
}
}))
PYEOF
22 changes: 22 additions & 0 deletions .claude/hooks/protect-main-branch.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#!/bin/bash
# PreToolUse hook: block direct commits or pushes to main
set -euo pipefail

INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')

# Block any git push that targets main (including HEAD:main, refs/heads/main)
if echo "$COMMAND" | grep -qE '\bgit\b.*\bpush\b' && \
echo "$COMMAND" | grep -qE '(\s|:)main(\s|$)'; then
printf '{"continue": false, "stopReason": "Direct push to main is blocked. Create a branch and open a PR instead."}'
exit 0
fi

# Block git commit when currently on main
if echo "$COMMAND" | grep -qE '\bgit\b.*\bcommit\b'; then
CURRENT_BRANCH=$(git branch --show-current 2>/dev/null || echo "")
if [ "$CURRENT_BRANCH" = "main" ]; then
printf '{"continue": false, "stopReason": "Direct commit to main is blocked. Create a branch first: git checkout -b <branch-name>"}'
exit 0
fi
fi
51 changes: 51 additions & 0 deletions .claude/hooks/require-changelog-entry.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/bin/bash
# PreToolUse hook: block `gh pr merge` unless this branch adds a specs/CHANGELOG.md entry.
#
# A hook can't *write* the entry (hooks are deterministic shell commands, not authors) —
# it enforces that one exists and tells the agent to write it. The signal is the branch
# diff: if CHANGELOG.md isn't touched relative to main, no entry was added.
#
# Self-gates on the command text rather than relying on the settings.json `if:` filter,
# so it stays correct even if that filter doesn't apply.
set -euo pipefail

INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')

# Not a merge → nothing to enforce.
echo "$COMMAND" | grep -qE '\bgh\b\s+pr\s+merge\b' || exit 0

CHANGELOG="specs/CHANGELOG.md"

# Compare against the upstream default branch; fall back to a local main.
BASE=""
for ref in origin/main main; do
if git rev-parse --verify --quiet "$ref" >/dev/null 2>&1; then BASE="$ref"; break; fi
done

# No main to compare against — don't block on an unanswerable question.
[ -z "$BASE" ] && exit 0

# `...` diffs against the merge-base, so this is "what this branch changed", not
# "how this branch differs from a main that moved on".
if git diff --name-only "$BASE...HEAD" -- "$CHANGELOG" | grep -q .; then
exit 0
fi

BRANCH=$(git branch --show-current 2>/dev/null || echo "?")
python3 - "$BRANCH" <<'PYEOF'
import json, sys
branch = sys.argv[1]
print(json.dumps({
"continue": False,
"stopReason": (
f"Merge blocked: this branch ({branch}) adds no entry to specs/CHANGELOG.md.\n"
"Add one at the top before merging — append-only, never edit or reformat an existing entry.\n"
"Header (use the PR URL directly; the number is known once the PR is open):\n"
" ## [#NN](https://github.com/andre-salvati/databricks-template/pull/NN) · YYYY-MM-DD · <title>\n"
"Body: one unwrapped paragraph, around 1000 characters. Character count is the only limit —\n"
"do not hard-wrap, and do not pad a short entry to reach it.\n"
"Full rule: specs/workflow.md#changelog-discipline"
),
}))
PYEOF
97 changes: 97 additions & 0 deletions .claude/hooks/require-fresh-pr-description.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/bin/bash
# PreToolUse hook: block `gh pr merge` unless the PR description has been re-confirmed
# against the exact commit being merged.
#
# A hook cannot judge whether prose is true. What it can prove is that nobody has looked
# at the description since the branch last moved — the failure mode that actually bites,
# because pr-merge-description.sh copies the body into the merge commit message. A stale
# description does not just mislead on GitHub (where it can be edited); it becomes
# permanent git history.
#
# The contract is a sentinel line anywhere in the PR body:
#
# <!-- description-verified: <sha> -->
#
# Whoever writes or revises the description stamps it with the branch tip they checked it
# against. If the branch advances afterwards, the sentinel no longer matches and the merge
# is blocked until someone re-reads the description and re-stamps it. Re-stamping without
# reading is possible — this enforces a deliberate act, not honesty.
#
# Self-gates on the command text rather than relying on the settings.json `if:` filter, so
# it stays correct even if that filter doesn't apply.
set -euo pipefail

INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')

# Not a merge → nothing to enforce.
echo "$COMMAND" | grep -qE '\bgh\b\s+pr\s+merge\b' || exit 0

# No gh, no PR, no git → don't block on an unanswerable question.
command -v gh >/dev/null 2>&1 || exit 0
HEAD_SHA=$(git rev-parse HEAD 2>/dev/null) || exit 0

# First non-flag token after `gh pr merge` is the PR ref, when one is given.
PR_ARG=$(echo "$COMMAND" | sed -nE 's/.*\bgh[[:space:]]+pr[[:space:]]+merge[[:space:]]+([^-[:space:]][^[:space:]]*).*/\1/p' | head -1)

if [ -n "$PR_ARG" ]; then
BODY=$(gh pr view "$PR_ARG" --json body -q .body 2>/dev/null) || exit 0
else
BODY=$(gh pr view --json body -q .body 2>/dev/null) || exit 0
fi

STAMP=$(printf '%s' "$BODY" \
| sed -nE 's/.*<!--[[:space:]]*description-verified:[[:space:]]*([0-9a-fA-F]{7,40})[[:space:]]*-->.*/\1/p' \
| tail -1)

# Stamp is a prefix of the tip (allows a short sha) → the description was confirmed here.
if [ -n "$STAMP" ] && [ "${HEAD_SHA#"$STAMP"}" != "$HEAD_SHA" ]; then
exit 0
fi

# Build the "what changed since you last looked" list, when the stamp names a real commit.
NEW_COMMITS=""
if [ -n "$STAMP" ] && git cat-file -e "${STAMP}^{commit}" 2>/dev/null; then
NEW_COMMITS=$(git log --oneline --no-decorate "${STAMP}..HEAD" 2>/dev/null | head -10)
fi

python3 - "$HEAD_SHA" "$STAMP" "$NEW_COMMITS" <<'PYEOF'
import json, sys

head, stamp, new_commits = sys.argv[1], sys.argv[2], sys.argv[3]
short = head[:7]

if not stamp:
why = (
"The PR description carries no `description-verified` stamp, so there is no evidence\n"
"anyone has checked it against what is actually being merged."
)
else:
why = (
f"The PR description was last verified at {stamp}, but the branch tip is now {short}.\n"
"It describes an older state of this branch."
)
if new_commits:
why += "\n\nLanded since it was verified:\n" + "\n".join(
" " + line for line in new_commits.splitlines()
)

print(json.dumps({
"continue": False,
"stopReason": (
"Merge blocked: the PR description has not been confirmed against this commit.\n\n"
f"{why}\n\n"
"This matters more than it looks: pr-merge-description.sh copies the body into the\n"
"merge commit message, so whatever is there now becomes permanent history.\n\n"
"To clear this:\n"
" 1. Re-read the description against the branch as it stands "
"(`git log --oneline origin/main..HEAD`).\n"
" 2. Update What / Why / How / Validation / Impact in prod so they match reality —\n"
" including removing claims that were true when written and are not now.\n"
f" 3. Stamp it by putting this line at the end of the body:\n"
f" <!-- description-verified: {head} -->\n"
" 4. Push the updated body with `gh pr edit <n> --body-file <file>`, then merge.\n\n"
"Full template: .github/PULL_REQUEST_TEMPLATE.md · rule: specs/workflow.md"
),
}))
PYEOF
37 changes: 37 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(gh pr merge*)",
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/require-fresh-pr-description.sh\"",
"timeout": 15,
"statusMessage": "Checking the PR description is current..."
},
{
"type": "command",
"if": "Bash(gh pr merge*)",
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/pr-merge-description.sh\"",
"timeout": 15,
"statusMessage": "Updating PR description and injecting into merge commit..."
},
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/protect-main-branch.sh\"",
"timeout": 10,
"statusMessage": "Checking branch protection..."
},
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/require-changelog-entry.sh\"",
"timeout": 10,
"statusMessage": "Checking for a CHANGELOG entry..."
}
]
}
]
}
}
Loading
Loading