Skip to content

Commit eb9711b

Browse files
2witstudiosclaude
andcommitted
chore: Add pre-commit hooks and commit message validation
Expand pre-commit to check Rust formatting, Swift formatting, and file hygiene (large files, merge markers, secrets). Add commit-msg hook enforcing Conventional Commits with sentence case. Add just targets for Swift formatting. Each check is independently skippable via SKIP_HOOKS. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b1ef7a1 commit eb9711b

3 files changed

Lines changed: 346 additions & 4 deletions

File tree

.githooks/commit-msg

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
# ──────────────────────────────────────────────────────────────
5+
# Commit-msg hook for PurePoint
6+
#
7+
# Enforces: Conventional Commits + sentence case
8+
# Format: type[(scope)]: Description starting with capital
9+
# Skip: SKIP_HOOKS=commit-msg git commit ...
10+
# SKIP_HOOKS=all git commit ...
11+
# Bypass: git commit --no-verify
12+
# ──────────────────────────────────────────────────────────────
13+
14+
# Skip if requested
15+
if [[ "${SKIP_HOOKS:-}" == "all" ]] || echo ",${SKIP_HOOKS:-}," | grep -q ",commit-msg,"; then
16+
exit 0
17+
fi
18+
19+
MSG_FILE="$1"
20+
MSG=$(head -1 "$MSG_FILE")
21+
22+
# Allow merge commits
23+
if echo "$MSG" | grep -qE '^Merge '; then
24+
exit 0
25+
fi
26+
27+
# Allow revert commits
28+
if echo "$MSG" | grep -qE '^Revert '; then
29+
exit 0
30+
fi
31+
32+
# Allow fixup/squash commits (with warning)
33+
if echo "$MSG" | grep -qE '^(fixup|squash)! '; then
34+
echo "commit-msg: allowing $( echo "$MSG" | cut -d'!' -f1 )! commit (remember to autosquash)"
35+
exit 0
36+
fi
37+
38+
# ── Validation ───────────────────────────────────────────────
39+
40+
TYPES="feat|fix|chore|docs|refactor|test|ci|perf|style|build"
41+
# Full pattern: type[(scope)]: Uppercase description
42+
PATTERN="^(${TYPES})(\([a-zA-Z0-9_-]+\))?: [A-Z]"
43+
44+
ERRORS=()
45+
46+
# Check max length
47+
if (( ${#MSG} > 72 )); then
48+
ERRORS+=("Message is ${#MSG} chars (max 72)")
49+
fi
50+
51+
# Check trailing period
52+
if echo "$MSG" | grep -qE '\.$'; then
53+
ERRORS+=("Remove trailing period")
54+
fi
55+
56+
# Check format
57+
if ! echo "$MSG" | grep -qE "$PATTERN"; then
58+
# Diagnose specific issue
59+
if ! echo "$MSG" | grep -qE "^(${TYPES})"; then
60+
# Check for WIP
61+
if echo "$MSG" | grep -qiE '^WIP'; then
62+
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
63+
if [[ "$BRANCH" == "main" || "$BRANCH" == "master" ]]; then
64+
ERRORS+=("WIP commits not allowed on $BRANCH")
65+
else
66+
exit 0
67+
fi
68+
else
69+
ERRORS+=("Must start with a type: feat, fix, chore, docs, refactor, test, ci, perf, style, build")
70+
fi
71+
elif ! echo "$MSG" | grep -qE "^(${TYPES})(\([a-zA-Z0-9_-]+\))?: "; then
72+
ERRORS+=("Missing ': ' after type/scope (use 'type: ' or 'type(scope): ')")
73+
elif ! echo "$MSG" | grep -qE "^(${TYPES})(\([a-zA-Z0-9_-]+\))?: [A-Z]"; then
74+
ERRORS+=("Description must start with uppercase (sentence case)")
75+
fi
76+
fi
77+
78+
# ── Report ───────────────────────────────────────────────────
79+
80+
if (( ${#ERRORS[@]} > 0 )); then
81+
echo ""
82+
echo "commit-msg: INVALID commit message"
83+
echo ""
84+
echo " Got: $MSG"
85+
echo ""
86+
for err in "${ERRORS[@]}"; do
87+
echo " Error: $err"
88+
done
89+
echo ""
90+
echo " Format: type[(scope)]: Description starting with capital"
91+
echo " Types: feat fix chore docs refactor test ci perf style build"
92+
echo " Examples: feat: Add workspace command"
93+
echo " fix(cli): Resolve manifest parsing error"
94+
echo " chore: Update dependencies"
95+
echo ""
96+
97+
# Suggest a fix if we can detect the type
98+
TYPE_MATCH=$(echo "$MSG" | grep -oE "^(${TYPES})" || true)
99+
if [[ -n "$TYPE_MATCH" ]]; then
100+
# Extract description after type[(scope)]:
101+
DESC=$(echo "$MSG" | sed -E "s/^(${TYPES})(\([a-zA-Z0-9_-]+\))?:? *//")
102+
SCOPE=$(echo "$MSG" | grep -oE '\([a-zA-Z0-9_-]+\)' || true)
103+
if [[ -n "$DESC" ]]; then
104+
# Capitalize first letter of description
105+
FIRST=$(echo "${DESC:0:1}" | tr '[:lower:]' '[:upper:]')
106+
REST="${DESC:1}"
107+
# Remove trailing period
108+
REST="${REST%.}"
109+
SUGGESTION="${TYPE_MATCH}${SCOPE}: ${FIRST}${REST}"
110+
# Truncate if needed
111+
if (( ${#SUGGESTION} > 72 )); then
112+
SUGGESTION="${SUGGESTION:0:69}..."
113+
fi
114+
echo " Suggested: $SUGGESTION"
115+
echo ""
116+
fi
117+
fi
118+
119+
echo " Skip: SKIP_HOOKS=commit-msg git commit ..."
120+
echo " Bypass: git commit --no-verify"
121+
exit 1
122+
fi

.githooks/pre-commit

Lines changed: 192 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,196 @@
11
#!/usr/bin/env bash
22
set -euo pipefail
33

4-
# Only check formatting when Rust files are staged
5-
if git diff --cached --name-only --diff-filter=ACM | grep -q '\.rs$'; then
6-
echo "pre-commit: checking Rust formatting..."
7-
cargo fmt --all -- --check
4+
# ──────────────────────────────────────────────────────────────
5+
# Pre-commit hook for PurePoint
6+
#
7+
# Checks: rust-fmt, swift-fmt, hygiene
8+
# Skip individual: SKIP_HOOKS=rust-fmt,hygiene git commit ...
9+
# Skip all: SKIP_HOOKS=all git commit ...
10+
# Bypass entirely: git commit --no-verify
11+
# ──────────────────────────────────────────────────────────────
12+
13+
FAILED=0
14+
15+
# Helpers ─────────────────────────────────────────────────────
16+
17+
skip_check() {
18+
local name="$1"
19+
[[ "${SKIP_HOOKS:-}" == "all" ]] && return 0
20+
echo ",${SKIP_HOOKS:-}," | grep -q ",$name," && return 0
21+
return 1
22+
}
23+
24+
now_ms() {
25+
perl -MTime::HiRes=time -e 'printf "%.0f\n", time * 1000'
26+
}
27+
28+
print_timing() {
29+
local label="$1" start="$2" end="$3"
30+
local elapsed=$(( end - start ))
31+
if (( elapsed >= 1000 )); then
32+
printf " [%s: %d.%03ds]\n" "$label" $((elapsed / 1000)) $((elapsed % 1000))
33+
else
34+
printf " [%s: %dms]\n" "$label" "$elapsed"
35+
fi
36+
}
37+
38+
# Staged files (cached once) ──────────────────────────────────
39+
40+
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
41+
42+
has_staged() {
43+
echo "$STAGED_FILES" | grep -q "$1"
44+
}
45+
46+
# ══════════════════════════════════════════════════════════════
47+
# Check: Rust formatting
48+
# ══════════════════════════════════════════════════════════════
49+
50+
if ! skip_check "rust-fmt" && has_staged '\.rs$'; then
51+
echo "pre-commit: checking Rust formatting..."
52+
start=$(now_ms)
53+
54+
if ! cargo fmt --all -- --check; then
55+
echo ""
56+
echo " FAILED: Rust formatting"
57+
echo " Fix: cargo fmt --all"
58+
echo ""
59+
FAILED=1
60+
fi
61+
62+
end=$(now_ms)
63+
print_timing "rust-fmt" "$start" "$end"
64+
fi
65+
66+
# ══════════════════════════════════════════════════════════════
67+
# Check: Swift formatting
68+
# ══════════════════════════════════════════════════════════════
69+
70+
if ! skip_check "swift-fmt" && has_staged '\.swift$'; then
71+
SWIFT_FORMAT=""
72+
73+
if command -v swift-format &>/dev/null; then
74+
SWIFT_FORMAT="swift-format"
75+
elif xcrun --find swift-format &>/dev/null 2>&1; then
76+
SWIFT_FORMAT="$(xcrun --find swift-format)"
77+
fi
78+
79+
if [[ -z "$SWIFT_FORMAT" ]]; then
80+
echo "pre-commit: swift-format not found, skipping Swift format check"
81+
echo " Install: brew install swift-format"
82+
else
83+
echo "pre-commit: checking Swift formatting..."
84+
start=$(now_ms)
85+
86+
SWIFT_STAGED=$(echo "$STAGED_FILES" | grep '\.swift$' || true)
87+
SWIFT_FAILED=0
88+
89+
while IFS= read -r file; do
90+
[[ -z "$file" ]] && continue
91+
if ! "$SWIFT_FORMAT" lint --strict "$file" 2>&1; then
92+
SWIFT_FAILED=1
93+
fi
94+
done <<< "$SWIFT_STAGED"
95+
96+
if (( SWIFT_FAILED )); then
97+
echo ""
98+
echo " FAILED: Swift formatting"
99+
echo " Fix: just fmt-swift"
100+
echo ""
101+
FAILED=1
102+
fi
103+
104+
end=$(now_ms)
105+
print_timing "swift-fmt" "$start" "$end"
106+
fi
107+
fi
108+
109+
# ══════════════════════════════════════════════════════════════
110+
# Check: File hygiene
111+
# ══════════════════════════════════════════════════════════════
112+
113+
if ! skip_check "hygiene" && [[ -n "$STAGED_FILES" ]]; then
114+
echo "pre-commit: checking file hygiene..."
115+
start=$(now_ms)
116+
HYGIENE_FAILED=0
117+
118+
# --- Large files (>1MB) ---
119+
while IFS= read -r file; do
120+
[[ -z "$file" ]] && continue
121+
[[ ! -f "$file" ]] && continue
122+
size=$(wc -c < "$file" | tr -d ' ')
123+
if (( size > 1048576 )); then
124+
size_mb=$(echo "scale=1; $size / 1048576" | bc)
125+
echo " BLOCKED: $file is ${size_mb}MB (limit: 1MB)"
126+
HYGIENE_FAILED=1
127+
fi
128+
done <<< "$STAGED_FILES"
129+
130+
# --- Merge conflict markers (text files only) ---
131+
while IFS= read -r file; do
132+
[[ -z "$file" ]] && continue
133+
[[ ! -f "$file" ]] && continue
134+
# Skip binary files
135+
if file --brief --mime "$file" | grep -q 'charset=binary'; then
136+
continue
137+
fi
138+
if grep -nE '^(<{7}|>{7}|={7})( |$)' "$file" > /dev/null 2>&1; then
139+
echo " BLOCKED: $file contains merge conflict markers"
140+
HYGIENE_FAILED=1
141+
fi
142+
done <<< "$STAGED_FILES"
143+
144+
# --- Secret patterns ---
145+
SECRET_PATTERNS=(
146+
'AKIA[0-9A-Z]{16}' # AWS access key
147+
'-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----' # Private keys
148+
'ghp_[0-9a-zA-Z]{36}' # GitHub PAT
149+
'gho_[0-9a-zA-Z]{36}' # GitHub OAuth
150+
'ghu_[0-9a-zA-Z]{36}' # GitHub user token
151+
'ghs_[0-9a-zA-Z]{36}' # GitHub server token
152+
'xoxb-[0-9a-zA-Z-]+' # Slack bot token
153+
'xoxp-[0-9a-zA-Z-]+' # Slack user token
154+
'xoxa-[0-9a-zA-Z-]+' # Slack app token
155+
'sk-[0-9a-zA-Z]{20,}' # API keys (OpenAI, etc.)
156+
'password\s*=\s*["\x27][^"\x27]{4,}' # password assignments
157+
'api[_-]?key\s*=\s*["\x27][^"\x27]{8,}' # api key assignments
158+
)
159+
160+
while IFS= read -r file; do
161+
[[ -z "$file" ]] && continue
162+
[[ ! -f "$file" ]] && continue
163+
# Skip binary files
164+
if file --brief --mime "$file" | grep -q 'charset=binary'; then
165+
continue
166+
fi
167+
for pattern in "${SECRET_PATTERNS[@]}"; do
168+
if grep -nEi "$pattern" "$file" > /dev/null 2>&1; then
169+
echo " BLOCKED: $file may contain secrets (matched: ${pattern:0:30}...)"
170+
HYGIENE_FAILED=1
171+
break
172+
fi
173+
done
174+
done <<< "$STAGED_FILES"
175+
176+
if (( HYGIENE_FAILED )); then
177+
echo ""
178+
echo " FAILED: File hygiene checks"
179+
echo " Skip: SKIP_HOOKS=hygiene git commit ..."
180+
echo ""
181+
FAILED=1
182+
fi
183+
184+
end=$(now_ms)
185+
print_timing "hygiene" "$start" "$end"
186+
fi
187+
188+
# ══════════════════════════════════════════════════════════════
189+
190+
if (( FAILED )); then
191+
echo "pre-commit: BLOCKED (see above)"
192+
echo " Skip individual checks: SKIP_HOOKS=<name>[,<name>] git commit ..."
193+
echo " Skip all pre-commit: SKIP_HOOKS=all git commit ..."
194+
echo " Bypass entirely: git commit --no-verify"
195+
exit 1
8196
fi

justfile

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@ destination := "platform=macOS"
55
# One-time post-clone setup
66
setup:
77
git config core.hooksPath .githooks
8+
chmod +x .githooks/*
89
rustup show
10+
@if command -v swift-format &>/dev/null || xcrun --find swift-format &>/dev/null 2>&1; then \
11+
echo "swift-format: found"; \
12+
else \
13+
echo "swift-format: not found (optional — install with: brew install swift-format)"; \
14+
fi
915

1016
# Format Rust code
1117
fmt:
@@ -15,6 +21,32 @@ fmt:
1521
fmt-check:
1622
cargo fmt --all -- --check
1723

24+
# Format Swift code
25+
fmt-swift:
26+
#!/usr/bin/env bash
27+
set -euo pipefail
28+
if command -v swift-format &>/dev/null; then
29+
SF="swift-format"
30+
elif xcrun --find swift-format &>/dev/null 2>&1; then
31+
SF="$(xcrun --find swift-format)"
32+
else
33+
echo "swift-format not found. Install: brew install swift-format" && exit 1
34+
fi
35+
find apps/ -name '*.swift' -print0 | xargs -0 "$SF" format -i
36+
37+
# Check Swift formatting (CI)
38+
fmt-swift-check:
39+
#!/usr/bin/env bash
40+
set -euo pipefail
41+
if command -v swift-format &>/dev/null; then
42+
SF="swift-format"
43+
elif xcrun --find swift-format &>/dev/null 2>&1; then
44+
SF="$(xcrun --find swift-format)"
45+
else
46+
echo "swift-format not found. Install: brew install swift-format" && exit 1
47+
fi
48+
find apps/ -name '*.swift' -print0 | xargs -0 "$SF" lint --strict
49+
1850
# Run clippy lints
1951
lint:
2052
RUSTFLAGS="-D warnings" cargo clippy --all-targets

0 commit comments

Comments
 (0)