Um repositório. Vários agentes de código. Uma branch que precisa estar sempre pronta pra deploy.
O setup inteiro é esse, e ele é mais estranho do que parece escrito. Os agentes rodam em worktrees paralelas — uma por pull request — e cada um consegue commitar, assinar, dar push, fazer rebase e, se decidir que os checks locais estão no caminho, pular todos. Eles são rápidos. Acertam quase sempre. E o “quase” é o problema todo: a falha pra qual você projeta não é o agente que trava, é o que está errado com confiança às 3 da manhã, sem ninguém olhando.
Aí você recorre aos git hooks
. Pre-commit linta. Pre-push roda o build. Commit-msg cobra o formato. Por um tempo parece governança. Não é. É conselho — e qualquer coisa que rode git pode recusar conselho.
O hook que dá pra pular não é gate
A descrição honesta de um git hook: um script que o cliente roda em seu nome, quando está a fim. --no-verify pula pre-commit e commit-msg num flag só. LEFTHOOK_EXCLUDE=lint derruba um comando específico. Apague .git/hooks/ e todo hook some; o repositório nem fica sabendo. Um agente que ouviu uma única vez que “o eval do pre-push é lento” acha o --no-verify sozinho. Gate que o ator consegue dispensar com um aceno não é gate — é sugestão bem-intencionada.
As restrições de dev solo afiam o corte. Não existe segundo humano pra revisar: você não pode aprovar o próprio pull request, e não tem mais ninguém. O loop local tem orçamento de latência — se o sweep de pre-commit custa dez segundos, os agentes desviam dele, e check que ninguém roda não protege nada. E o que está sob guarda é uma branch que deploya: um push ruim é evento de produção, não orgulho ferido. Eu já vi a falha exata: um fix estrutural encalhado numa troca de branch, descartado em silêncio, o build seguinte quietamente sem ele. Sem erro. Só sumiu.
O Y-statement que assinei:
No contexto de um dev solo rodando vários agentes de código contra uma única branch que deploya, enfrentando atores capazes de burlar qualquer check local e sem um segundo humano pra pegar o erro, decidimos dividir a imposição entre hooks client-side consultivos pela latência e um ruleset server-side vinculante pela integridade, para obter uma branch sempre pronta pra release sem frear o inner loop, aceitando que os hooks são teatro a menos que o servidor imponha os mesmos invariantes por baixo.
Duas camadas, dois trabalhos
A divisão não é redundância. É divisão de trabalho: os hooks compram velocidade, o ruleset compra a garantia. Nenhum faz o trabalho do outro, e é confundindo os dois que repositório acaba “protegido” por um script que todo contribuidor já aprendeu a pular.
No lado do cliente, os hooks são conselho que você realmente quer seguir. O lefthook
espalha o sweep de pre-commit em paralelo — formato de conventional commit, varredura de segredo, formatador, o lint do próprio projeto — e limpa uma árvore de 258 arquivos em 0.19s, um commit staged de um a três arquivos em sub-50ms. Rápido o bastante pra ninguém se ressentir, que é a única razão de sobreviver. Um hook de pre-push roda o check caro — um eval cross-host, 5-8s quente, 30-60s depois de um bump de dependência — e recusa os dois movimentos que mais custam pra desfazer: push direto na main e force-push que não é descendente fast-forward. Quando a história é reescrita de verdade, um hook de post-commit já espelhou o tip antigo pra refs/backup/<host>/<branch>, e o reflog segura commit descartado por 180d. Conselho, com rede embaixo.
No lado do servidor, o ruleset é a lei. Não “deveria”, não “por favor” — o servidor recusa o push. Um ruleset de repositório do GitHub
codifica os invariantes que não dobram: required_status_checks com strict up-to-date, required_linear_history, required_signatures
, non_fast_forward e proibição de deleção, resolução de thread de review e — a linha que se lê como sentença — bypass_actors: []. Ninguém passa por cima. Admin incluído.
A fitness function não é o ruleset. É a checagem de que o ruleset continua em vigor:
# fitness gate — the law must still be enforced, not merely written down.
# Run it in CI: a ruleset that quietly flips to disabled is a hook nobody runs,
# except this failure mode you can actually detect.
gh api "repos/$REPO/rulesets/$RULESET_ID" --jq '
(.enforcement == "active")
and (.bypass_actors | length == 0) # nobody bypasses, admins included
and (.rules | map(.type) | contains([
"required_signatures",
"non_fast_forward",
"required_linear_history",
"pull_request",
"required_status_checks"
]))
' | grep -qx true || { echo "ruleset drifted — main is unguarded"; exit 1; }Um detalhe que a doc enterra: o required_status_checks do ruleset só pode nomear jobs que o seu CI realmente produz. Liste um check que nunca roda e o ruleset tranca a main atrás de um status eternamente pendente — você impôs um deadlock, não uma política.
As decisões, com preço
Hooks consultivos, nunca mandatórios. Custo: dá pra pular, por construção. Benefício: nunca te trancam fora do próprio repositório no meio de um incidente, e continuam rápidos o bastante pra manter. Reconsiderar: nunca — enquanto o servidor impuser a mesma regra. No instante em que um hook é a única coisa entre um agente e a main, ele já falhou; a conta só não chegou ainda.
Ruleset, não as branch-protection rules legadas. Rulesets empilham, são reutilizáveis na org inteira e deixam a lista de bypass explícita. bypass_actors: [] é prosa auditável. A UI antiga de branch protection escondia o “admins podem passar” num checkbox que ninguém relê — exatamente onde mora o próximo incidente.
Zero aprovações obrigatórias. Parece errado até você dizer em voz alta: dev solo não pode aprovar o próprio pull request, então regra exigindo uma aprovação humana é regra exigindo que você nunca faça merge. O revisor vira o required status check, não uma pessoa — 0 aprovações em vez de 1. Custo: sem carimbo humano. Benefício: o merge de fato acontece, guardado por um check que não dorme, não tira férias e não carimba no automático às 3 da manhã.
O anti-padrão atravessa as três decisões, e tem nome: cultura de --no-verify — tratar o hook local como o gate e depois se surpreender quando algo passa por fora. O primo dele é o bypass de admin, aquela entrada em bypass_actors que você adiciona “só dessa vez” durante um incidente e nunca remove. Os dois movem a fronteira real do servidor pra um hábito, em silêncio. Hábito não sobrevive a uma noite cansada; recusa do servidor sobrevive.
O que o hook não consegue prometer
Hooks mentem, estruturalmente — não por malícia, mas porque rodam no cliente, e o cliente é justamente o que você está tentando conter. Um commit assinado prova quem foi o autor. Não prova que o pre-commit rodou. Só o servidor, recusando o push, prova algo que o autor não poderia forjar — e essa é a razão inteira de o servidor segurar os invariantes que importam.
É também por isso que imposição que briga com o operário acaba deletada. Eu já rodei um guard de pre-tool que bloqueava agente de trocar de branch dentro de worktree; ele pegava erro de verdade e também bloqueava trabalho legítimo, então caiu, e a disciplina que ele impunha virou regra escrita. Essa é a lição de verdade, e ela corta contra o reflexo de adicionar mais hooks: guard local hostil o bastante pra valer a pena burlar vai ser burlado — ou removido pela pessoa que ele atrasa, que é você. Ponha no servidor os invariantes que não podem dobrar, onde dobrar não está no cardápio, e mantenha a camada local útil o bastante pra ninguém querer desligar.
Quando isso é exagero? Repositório com um humano cuidadoso e zero agente não precisa da cerimônia — um hook de pre-commit e uma branch protection simples bastam. Defense-in-depth se paga exatamente quando o ator de quem você se guarda é rápido, numeroso e de vez em quando erra num horário em que nenhum revisor está acordado pra pegar. É nessa sala que uma frota de agentes te coloca.
Os hooks deixam o caminho bom rápido. O ruleset torna o caminho ruim impossível. Só um dos dois é negociável — e não é o que mora no servidor.
Stack
- Hooks locais: lefthook
com fan-out paralelo do pre-commit + um punhado de git hooks declarativos (guard de pre-push, espelho de backup no
post-commit,prepare-commit-msg) - Formato de commit: Conventional Commits — cobrado pelo
commit-msglocal e re-checado como required status check no CI, pro passe local nunca segurar nada sozinho - Assinatura: commits assinados por SSH, impostos no servidor via
required_signatures - Lei do servidor: ruleset de repositório do GitHub —
enforcement: active,bypass_actors: [], required status checks (strict up-to-date), histórico linear obrigatório, sem force-push, sem deleção, resolução de thread de review,0aprovações obrigatórias - Recuperação: espelho
refs/backup/<host>/<branch>nopost-commit+ retenção de reflog (180d/90dpra objetos inalcançáveis) - CI: os required checks que são o gate de merge de verdade — lint e formatação, saúde de dependências, eval por host