🎯 O projeto — hook de validação de commits
O projeto final da Trilha 1 é construir um PreToolUse hook que intercepta git commits e bloqueia mensagens que não são descritivas. Este é o caso de uso mais comum de hook de validação e aplica todos os conceitos dos módulos anteriores.
📋 O que vamos construir
💡 Por que este projeto
Mensagens de commit ruins são o problema de qualidade mais comum em projetos de engenharia — e são fáceis de automatizar. Este hook é imediatamente útil, simples o suficiente para ser o primeiro, e complexo o suficiente para cobrir todos os conceitos da trilha.
📋 Escrevendo o SPEC antes do código
Antes de uma linha de código, escrevemos o SPEC. Isso força decisões difíceis quando ainda são baratas: o que é uma "mensagem ruim"? Como capturar a mensagem das diferentes flags do git commit?
📋 SPEC.md completo
# SPEC: validate-commit hook
## Objetivo
Bloquear git commits do Claude Code com mensagens que não são
descritivas — garantindo um histórico de git útil.
## Entrada
- Evento: PreToolUse
- Ferramenta: Bash
- Condição: comando contém "git commit"
## Critérios de BLOQUEIO
1. Mensagem tem menos de 20 caracteres
2. Mensagem é exatamente um dos termos genéricos:
fix, update, changes, wip, temp, test, misc, stuff
## Critérios de APROVAÇÃO (exceções)
1. git commit --amend (re-commit não precisa re-validar)
2. git commit --no-edit (merge commit automático)
3. Mensagem começa com "Merge " (merge commit)
4. Mensagem começa com "Revert " (revert automático)
5. Hook falha por qualquer razão (fail-open)
## Extração da mensagem
- Flag -m "mensagem": extrair entre aspas após -m
- Flag --message="mensagem": extrair valor após =
- Sem -m: git commit abrirá editor — não interceptar
## Invariantes
- Nunca bloqueia se jq não estiver disponível (fail-open)
- Sempre logar a decisão (aprovar ou bloquear) com timestamp
- Mensagem de erro é acionável: diz o que está errado E como corrigir
## Decisões de design
- Comprimento mínimo 20 chars: suficiente para uma descrição útil
- Lista de termos genéricos pequena: falsos positivos são piores que falsos negativos
- Não validar formato Conventional Commits: escopo desta versão é só comprimento
💻 Implementando o hook em shell
Com o SPEC em mãos, a implementação é direta. Cada decisão do SPEC tem correspondência exata no código — isso é o que um bom SPEC produz.
💻 validate-commit.sh completo
#!/bin/bash
# .claude/hooks/validate-commit.sh
# PreToolUse hook: valida mensagens de git commit
# SPEC: .claude/SPEC.md
set -uo pipefail # Não usar set -e — queremos controlar erros
LOG_FILE=".claude/logs/validate-commit.log"
mkdir -p "$(dirname "$LOG_FILE")"
# Logging com timestamp
log() {
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] validate-commit: $*" >> "$LOG_FILE"
}
# Fail-open: em qualquer erro inesperado, aprovar
fail_open() {
log "WARN: $* — fail-open, aprovando"
exit 0
}
log "Hook iniciado — PID: $$"
# Lê o payload JSON do stdin
PAYLOAD=$(cat) || fail_open "Falha ao ler stdin"
# Verifica se jq está disponível
if ! command -v jq &>/dev/null; then
fail_open "jq não encontrado"
fi
# Extrai ferramenta e comando
TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool.name // empty' 2>/dev/null) || fail_open "jq falhou ao parsear tool.name"
COMMAND=$(echo "$PAYLOAD" | jq -r '.tool.input.command // empty' 2>/dev/null) || fail_open "jq falhou ao parsear command"
log "Ferramenta: $TOOL_NAME"
# Só processa chamadas ao Bash
[ "$TOOL_NAME" = "Bash" ] || { log "Não é Bash — aprovando"; exit 0; }
# Só processa git commit
echo "$COMMAND" | grep -q 'git commit' || { log "Não é git commit — aprovando"; exit 0; }
log "Analisando: $COMMAND"
# Exceções: --amend, --no-edit
if echo "$COMMAND" | grep -qE -- '--amend|--no-edit'; then
log "Exceção --amend/--no-edit — aprovando"
exit 0
fi
# Extrai a mensagem de commit (flag -m ou --message)
MSG=""
if echo "$COMMAND" | grep -qE -- '-m\s+|--message=|--message\s+'; then
# Tenta extrair a mensagem das aspas após -m
MSG=$(echo "$COMMAND" | grep -oP '(?<=-m\s)["\x27]?\K[^"'\'']+(?=["\x27]?)' | head -1)
# Fallback: grep mais simples
if [ -z "$MSG" ]; then
MSG=$(echo "$COMMAND" | sed -nE "s/.*-m ['\"]([^'\"]+)['\"].*/\1/p")
fi
fi
# Se não encontrou mensagem (ex: commit sem -m, abrirá editor)
if [ -z "$MSG" ]; then
log "Mensagem não extraída — pode ser editor interativo — aprovando"
exit 0
fi
log "Mensagem extraída: '$MSG'"
# Exceções: merge e revert automático
if echo "$MSG" | grep -qE '^(Merge |Revert )'; then
log "Merge/Revert commit — aprovando"
exit 0
fi
# Validação 1: comprimento mínimo
MSG_LEN=${#MSG}
if [ "$MSG_LEN" -lt 20 ]; then
log "BLOQUEADO: mensagem muito curta ($MSG_LEN chars)"
echo "HOOK DE QUALIDADE: Mensagem de commit muito curta ($MSG_LEN caracteres).
Mínimo: 20 caracteres
Sua mensagem: '$MSG'
Exemplos de mensagens boas:
'feat: adiciona autenticação por OAuth2'
'fix: corrige cálculo de desconto no checkout'
'refactor: extrai lógica de validação para módulo separado'" >&2
exit 1
fi
# Validação 2: termos genéricos
GENERIC_TERMS="^(fix|update|changes|wip|temp|test|misc|stuff)$"
MSG_LOWER=$(echo "$MSG" | tr '[:upper:]' '[:lower:]' | tr -d ' ')
if echo "$MSG_LOWER" | grep -qiE "$GENERIC_TERMS"; then
log "BLOQUEADO: mensagem genérica '$MSG'"
echo "HOOK DE QUALIDADE: Mensagem de commit genérica.
Sua mensagem: '$MSG'
Mensagens genéricas como 'fix', 'update', 'wip' não descrevem
o que foi alterado. Use uma mensagem específica.
Exemplos:
'fix: corrige NullPointerException no módulo de usuários'
'update: atualiza dependências para versão LTS'" >&2
exit 1
fi
log "Aprovado: '$MSG' ($MSG_LEN chars)"
exit 0
🧪 Testando o fail-open
Antes de ativar o hook em produção, teste deliberadamente os cenários de falha. Você precisa ter confiança de que o hook não vai travar o Claude Code quando algo der errado.
🧪 Suite de testes completa
#!/bin/bash
# .claude/tests/test-validate-commit.sh
HOOK=".claude/hooks/validate-commit.sh"
PASS=0; FAIL=0
t() {
local name="$1" input="$2" expected_exit="$3"
actual=$(echo "$input" | bash "$HOOK" 2>/dev/null; echo $?)
actual_exit=$(echo "$input" | bash "$HOOK" > /dev/null 2>&1; echo $?)
if [ "$actual_exit" = "$expected_exit" ]; then
echo " ✓ $name"; PASS=$((PASS + 1))
else
echo " ✗ $name (esperado $expected_exit, obteve $actual_exit)"; FAIL=$((FAIL + 1))
fi
}
BASE='{"tool":{"name":"Bash","input":{"command":'
echo "=== Bloqueios esperados ==="
t "mensagem curta" "${BASE}\"git commit -m 'fix'\"}}" 1
t "mensagem genérica update" "${BASE}\"git commit -m 'update'\"}}" 1
t "mensagem genérica wip" "${BASE}\"git commit -m 'wip'\"}}" 1
echo "=== Aprovações esperadas ==="
t "mensagem boa" "${BASE}\"git commit -m 'feat: adiciona autenticação OAuth2'\"}}" 0
t "amend" "${BASE}\"git commit --amend --no-edit\"}}" 0
t "no-edit" "${BASE}\"git commit --no-edit\"}}" 0
t "merge commit" "${BASE}\"git commit -m 'Merge branch main'\"}}" 0
t "não é bash" '{"tool":{"name":"Read","input":{"file":"test"}}}' 0
t "não é git commit" "${BASE}\"git status\"}}" 0
echo "=== Fail-open ==="
t "payload vazio" '{}' 0
t "payload inválido" 'invalid json' 0
echo ""
echo "Resultado: $PASS passou, $FAIL falhou"
[ "$FAIL" = "0" ] && exit 0 || exit 1
💡 Testando sem jq instalado
Para simular jq ausente: PATH="" echo '{"tool":{"name":"Bash"...}}' | .claude/hooks/validate-commit.sh. O hook deve retornar exit 0 (fail-open) mesmo sem jq no PATH.
🔗 Integrando com settings.json
Com o hook testado, o último passo é registrá-lo no settings.json e verificar que o Claude Code o executa corretamente em um teste end-to-end real.
Dar permissão de execução
chmod +x .claude/hooks/validate-commit.sh
# Verifica: ls -la .claude/hooks/validate-commit.sh
# Deve mostrar: -rwxr-xr-x
Registrar no .claude/settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/validate-commit.sh"
}
]
}
]
}
}
Teste end-to-end no Claude Code
# Inicia o Claude Code no diretório do projeto
claude
# Teste de bloqueio — deve falhar com mensagem do hook:
"Execute: git commit -m 'fix'"
# Teste de aprovação — deve funcionar:
"Execute: git commit -m 'test: verifica que hook está funcionando'"
# Verifica o log:
"Mostre o conteúdo de .claude/logs/validate-commit.log"
🔄 Iteração incremental — melhorando o hook
O hook está funcional. Agora iteramos — cada melhoria é um commit separado, mantendo o histórico claro de por que cada feature foi adicionada.
Iteração 1: Modo DRY_RUN
Adiciona variável de ambiente para testar sem bloquear de verdade.
# Adicionar antes do exit 1 nos bloqueios:
if [ "${VALIDATE_COMMIT_DRY_RUN:-}" = "1" ]; then
log "DRY_RUN: teria bloqueado — $MSG"
exit 0
fi
# Uso: VALIDATE_COMMIT_DRY_RUN=1 claude
Commit: hook(validate-commit): adiciona modo DRY_RUN para testes não-destrutivos
Iteração 2: Estatísticas de uso
Conta quantos commits foram bloqueados vs aprovados.
STATS_FILE=".claude/state/commit-stats.json"
update_stats() {
local action="$1" # "blocked" ou "approved"
local current=$(cat "$STATS_FILE" 2>/dev/null || echo '{"blocked":0,"approved":0}')
local new=$(echo "$current" | jq ".$action += 1 | .last_action = \"$action\" | .last_at = \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"")
local tmp=$(mktemp "$(dirname "$STATS_FILE")/stats.XXXXXX.json")
echo "$new" > "$tmp" && mv "$tmp" "$STATS_FILE"
}
# Chamar antes de cada exit:
update_stats "blocked" && exit 1
update_stats "approved" && exit 0
Commit: hook(validate-commit): adiciona estatísticas de bloqueios em JSON
Iteração 3: Sugestão de mensagem melhorada
A mensagem de erro sugere o que o usuário deveria ter escrito.
# Na mensagem de bloqueio por comprimento, adicionar:
echo "Sua mensagem foi: '$MSG'
Que tal tentar algo como:
'feat: descreva aqui o que a mudança adiciona'
'fix: explique aqui o bug que foi corrigido'
'refactor: descreva o que foi reestruturado'" >&2
Commit: hook(validate-commit): melhora mensagem de erro com exemplos de formato
🏆 Parabéns! Você concluiu a Trilha 1
Você agora tem um hook de validação funcional e todo o conhecimento da base para construir sistemas mais complexos. Estes são os fundamentos que tornam possível tudo que vem na Trilha 2.
Próxima Trilha:
Trilha 2 — Construindo com Claude Code — como o Claudex foi construído do zero usando 7 prompts incrementais verificados