Você pediu ao coding agent para corrigir uma regra de desconto. O PR chega com mil linhas. O agente rodou o formatter dele no módulo inteiro, com a indentação que preferiu, quebrou as linhas onde quis e colocou espaço dentro de cada parêntese. Em algum ponto desse diff pode haver uma mudança de comportamento, e ela tem a mesma cara de todas as outras linhas. Enquanto isso, o branch vizinho mexeu em três métodos do mesmo arquivo, e o merge virou um conflito por linha.
A saída que propomos não usa modelo nem serviço externo. São dois git diff com --numstat, um com -w e outro sem, mais uma leitura do patch sem espaço. Com isso dá para decidir se o PR é recusado, se volta pedindo dois commits ou se vai para review. Todos os números abaixo saíram de uma fixture local descartável com git 2.53.0, sem rede, sem CI e sem API de modelo.
Não confunda com o gate de -DskipTests, que procura flags do Maven no comando e no POM. Este aqui olha o tamanho e a natureza do diff.
A fixture: três commits que parecem a mesma coisa
São três arquivos pequenos. Cada um foi commitado primeiro numa versão compacta e depois recebeu um commit de "reformat":
6e8db60 (HEAD -> main) Reformat Rates.properties (spaces around equals only)
5bbdc1d Add Rates.properties compact
65b875b Reformat OrderTotals and change cents < 0 to cents <= 0
aee2e3b Add OrderTotals compact
701207f Reformat DiscountPolicy (whitespace and wrapping only)
da7c8be Add DiscountPolicy with compact formatting
Só o OrderTotals muda comportamento. Repare que, para saber disso, você precisou ler a mensagem do commit. No PR do agente a mensagem diz "style cleanup".
Caso 1: DiscountPolicy, só espaço e quebra de linha
Depois do reformat, o arquivo ficou assim:
package com.example.pricing;
public final class DiscountPolicy
{
public int apply( int cents, boolean loyal )
{
if ( cents < 0 )
{
throw new IllegalArgumentException( "cents" );
}
int rate = loyal ? 15 : 5;
return cents
- ( cents * rate / 100 );
}
}
Chaves em linha própria, espaço dentro dos parênteses e o return quebrado em duas linhas. Nenhum identificador nem operador mudou. O diff:
$ git diff --stat da7c8be 701207f
DiscountPolicy.java | 16 +++++++++++-----
1 file changed, 11 insertions(+), 5 deletions(-)
$ git diff --numstat da7c8be 701207f
11 5 DiscountPolicy.java
$ git diff -w --stat da7c8be 701207f
DiscountPolicy.java | 14 ++++++++++----
1 file changed, 10 insertions(+), 4 deletions(-)
$ git diff -w --numstat da7c8be 701207f
10 4 DiscountPolicy.java
O --stat foi feito para gente ler. Ele desenha barra, trunca nome de arquivo comprido e acrescenta uma linha de resumo. O --numstat imprime adicionadas<TAB>removidas<TAB>caminho, com o caminho completo. A man page do git-diff diz que ele é "designed for easier machine consumption", e por isso é o que o gate usa.
O ponto que pega muita gente: -w não zera esse reformat. Ele cai de 11/5 para 10/4 e para aí. O -w (--ignore-all-space) ignora espaço dentro da linha, mas o formatter também juntou e quebrou linhas, e isso muda a contagem de linhas. Quebrar o return em duas linhas não é diferença de espaço para o git. Por isso a regra "recuse se git diff -w vier vazio" pega só o próximo caso.
Caso 2: Rates.properties, e por que -b não é -w
O agente colocou espaço em volta do =:
$ git diff --stat HEAD~1 HEAD
Rates.properties | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
$ git diff --numstat HEAD~1 HEAD
2 2 Rates.properties
$ git diff -w --numstat HEAD~1 HEAD
(sem saída, exit 0)
Aqui o -w zera tudo, porque nenhuma linha foi criada nem quebrada. O que mudou foi o espaço dentro de cada linha.
Quem tenta resolver com -b (--ignore-space-change) continua vendo o diff:
$ git diff -b --stat HEAD~1 HEAD
Rates.properties | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
$ git diff -b HEAD~1 HEAD
diff --git a/Rates.properties b/Rates.properties
index f640581..757d537 100644
--- a/Rates.properties
+++ b/Rates.properties
@@ -1,2 +1,2 @@
-loyal.rate=15
-guest.rate=5
+loyal.rate = 15
+guest.rate = 5
O -b ignora mudança na quantidade de espaço. Se antes havia um espaço e agora há quatro, ele não acusa nada. Em loyal.rate=15 não havia espaço nenhum, então o espaço foi adicionado, e o -b mostra a linha como alterada. Para o gate, use -w.
Caso 3: OrderTotals, o que o --stat não pega
Esse commit reformatou o arquivo e trocou um operador:
$ git diff --stat aee2e3b 65b875b
OrderTotals.java | 19 ++++++++++++++-----
1 file changed, 14 insertions(+), 5 deletions(-)
$ git diff --numstat aee2e3b 65b875b
14 5 OrderTotals.java
$ git diff -w --numstat aee2e3b 65b875b
14 5 OrderTotals.java
O 14/5 está na mesma ordem de grandeza do 11/5 do reformat puro, e o -w devolve os mesmos 14/5 do diff normal. Só pela contagem de linhas não dá para classificar este PR. A mudança aparece quando você lê o patch sem espaço:
$ git diff -w aee2e3b 65b875b | grep -nE '^[-+].*(<=|<|>)'
11:- if (cents < 0) {
19:+ if ( cents <= 0 )
O < virou <=. Antes, um pedido de valor zero passava pelo if e seguia para o cálculo. Agora ele retorna 0 direto. É uma mudança de fronteira, e ocupa uma linha de diff, exatamente como uma linha que só ganhou espaço dentro do parêntese. O resultado:
if ( cents <= 0 )
{
return 0;
}
Em mil linhas de reformat, essa linha passa no review do jeito de sempre: o revisor rola até a metade, conclui que é tudo estilo e aprova junto.
Duas métricas: files e logic
O gate trabalha com duas contagens, e as duas saem do git local:
filesé o número de linhas degit diff --numstat <base> <head>, ou seja, quantos arquivos o PR toca.logicé a soma das colunas 1 e 2 degit diff -w --numstat <base> <head>, ou seja, o que sobra quando o espaço dentro da linha é ignorado.
Não conte arquivos com git diff --stat | wc -l. Na fixture, isso devolveu 2 para um range de um arquivo, porque a linha de resumo 1 file changed entra na conta. Com --numstat, o mesmo range dá 1:
$ git diff --numstat da7c8be 701207f | wc -l
1
O script abaixo usa só a biblioteca padrão do Python e faz as duas contas. Ele não fez parte do run da fixture. Os valores da tabela que vem depois foram calculados a partir das saídas mostradas acima.
#!/usr/bin/env python3
"""Gate de reformat: arquivos tocados vs linhas que sobram com -w.
Uso: python3 reformat_gate.py <base> <head>
Só biblioteca padrão. Não chama modelo nem rede.
"""
import subprocess
import sys
def numstat(base, head, *flags):
out = subprocess.run(
["git", "diff", *flags, "--numstat", base, head],
check=True,
capture_output=True,
text=True,
).stdout
return [line.split("\t", 2) for line in out.splitlines()]
def lines_changed(rows):
# Binário sai como "-<TAB>-<TAB>caminho": entra em files, não soma linha.
return sum(int(added) + int(deleted) for added, deleted, _ in rows if added != "-")
def main():
if len(sys.argv) != 3:
print("uso: reformat_gate.py <base> <head>", file=sys.stderr)
return 2
base, head = sys.argv[1], sys.argv[2]
raw = numstat(base, head)
ws = numstat(base, head, "-w")
files = len(raw)
churn = lines_changed(raw)
logic = lines_changed(ws)
print(f"files={files} churn={churn} logic={logic}")
if files == 0:
print("diff vazio")
return 0
if logic == 0:
print("RECUSAR: só espaço. Não há o que revisar.")
return 1
if files >= 3 and logic / churn < 0.10:
print("PEDIR DOIS COMMITS: formatter do repo num, lógica no outro.")
return 1
print(f"LER O PATCH: git diff -w {base} {head}")
return 0
if __name__ == "__main__":
sys.exit(main())
Aplicando aos três casos:
| caso | range | files | churn | logic | o patch -w mostra | decisão |
|---|---|---|---|---|---|---|
Rates.properties | 5bbdc1d..6e8db60 | 1 | 4 | 0 | nada | recusar sem discussão |
DiscountPolicy.java | da7c8be..701207f | 1 | 16 | 14 | só quebra de linha | recusar: reformat de módulo |
OrderTotals.java | aee2e3b..65b875b | 1 | 19 | 19 | < virou <= | pedir dois commits |
Os limiares saem desta fixture. Não são benchmark:
logic == 0. O PR é reformat puramente espacial. O script recusa sozinho.logic > 0com poucos arquivos. Aqui os números não decidem.DiscountPolicyeOrderTotalsficam na mesma faixa, e só a leitura do patch-wsepara os dois. Se o patch mostrar só rewrap, recuse e peça o PR de novo, sem reformat, ou com rebase em cima de uma base que já passou pelo formatter do repo. Se aparecer lógica, peça dois commits.files >= 3elogicabaixo de uns 10% do churn. É o PR de mil linhas, quase todo espaço e com alguma coisa real no meio. Não recuse pelo tamanho. Exija dois commits antes de abrir o review: um só com o formatter do repo e outro só com a lógica.
O reformat-only tem custo mesmo sem mudar comportamento. As 16 linhas de DiscountPolicy.java são 16 lugares onde o branch vizinho pode dar conflito, num PR que não entrega nada.
Formatter do repositório versus formatter do agente
O formatter do agente tem gosto. O do repositório tem versão. É essa diferença que torna o gate justo.
O estilo deveria estar declarado no repo. O EditorConfig declara as regras básicas (indent_style, indent_size, max_line_length, trim_trailing_whitespace, insert_final_newline, end_of_line), e um formatter pinado no build aplica essas regras. No Gradle, o Spotless está na versão 8.10.4 no Plugin Portal (com.diffplug.spotless, publicada em 08/10/2026), com as tasks spotlessCheck e spotlessApply. Existe também o spotless-maven-plugin. Confira a versão dele no Maven Central antes de pinar, porque não verificamos esse número aqui. O Spotless não foi aplicado na fixture: a demo é só git diff.
Se a base já passou pelo formatter pinado, rodar o mesmo formatter de novo não muda nada, e o arquivo sai com git diff vazio. Então um PR que reformatou o módulo inteiro mostra que o agente usou regras próprias (indentação, largura de linha, onde quebrar), e não as do repo. A checagem é barata, mas não foi executada neste run:
./gradlew spotlessApply
git diff --exit-code -- src/main/java/com/example/pricing/
Se o git diff --exit-code sair diferente de zero depois do spotlessApply do repo, a diferença restante é o que o formatter do repo não aceita. Ela tem que sair do PR.
Tudo isso depende de uma premissa. Se a base não está formatada, o reformat pode ser legítimo, mas ele precisa ser feito pelo formatter do repo, num commit próprio ou num PR próprio, e não como efeito colateral de uma tarefa de desconto.
Checklist antes de abrir o review
São seis passos, todos com git local, e nenhum chama modelo:
git diff --numstat <base> <head>: arquivos tocados e churn por arquivo.git diff -w --numstat <base> <head>: o que sobra ignorando espaço dentro da linha. Se vier vazio, recuse.git diff -w <base> <head>: o patch reduzido. É aqui que a lógica aparece, porque a contagem não mostra.- Opcional:
git diff -w <base> <head> | grep -nE '^[-+].*(<=|<|>)'para listar linhas com operador de comparação. Em Java de verdade,<também casa com todoList<String>, então use isso para guiar a leitura, não como veredito. - Se o repo tem formatter pinado, rode e veja se sobra diff (seção anterior).
- Decida: recusar (reformat-only ou churn desproporcional ao que a tarefa pedia) ou devolver pedindo dois commits, um com o formatter do repo e outro com a lógica.
Com os dois commits separados, o revisor confere o primeiro rodando o formatter e vendo o diff vazio, e lê o segundo linha por linha. O <= passa a ser a única coisa no commit dele.
Limites
- A contagem não classifica. 11/5 de estilo e 14/5 com mudança de fronteira têm a mesma cara. Quem decide é o patch
-w. -wpode esconder coisa. Uma mudança de espaço dentro de um literal de string, numa linha que mudou, pode sumir com-w. Isso não foi exercitado nesta fixture, então trate como cautela, não como resultado.- Parsing de
--numstat. Arquivo binário sai como--, e o script trata isso. Rename sai comoarch/{i386 => x86}/Makefile. Se o caminho tiver caractere incomum, use-z, que, segundo a doc, faz o git não alterar o pathname. - Fixture não é estatística. São três arquivos pequenos. O limiar de 10% é uma heurística para começar. Ajuste depois de olhar alguns PRs reais do seu repo.
Próximo passo
No próximo PR de coding agent, rode git diff --numstat e git diff -w --numstat contra a base antes de abrir qualquer arquivo. Se logic der zero, recuse. Se o PR tocar arquivos com churn muito maior do que a tarefa justificaria e não vier separado em dois commits, devolva pedindo o formatter do repo num commit e a lógica no outro. Só depois disso o review começa, e começa pelo patch -w.