Todos os artigos

// Knowledge.log — 技術記事

Como barrar o PR do coding agent que só reformatou o módulo

git diff --numstat e -w --numstat numa fixture: quando o PR do coding agent é só reformat, quando esconde um < virando <= e quando recusar.

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 de git diff --numstat <base> <head>, ou seja, quantos arquivos o PR toca.
  • logic é a soma das colunas 1 e 2 de git 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:

casorangefileschurnlogico patch -w mostradecisão
Rates.properties5bbdc1d..6e8db60140nadarecusar sem discussão
DiscountPolicy.javada7c8be..701207f11614só quebra de linharecusar: reformat de módulo
OrderTotals.javaaee2e3b..65b875b11919< 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 > 0 com poucos arquivos. Aqui os números não decidem. DiscountPolicy e OrderTotals ficam na mesma faixa, e só a leitura do patch -w separa 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 >= 3 e logic abaixo 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:

  1. git diff --numstat <base> <head>: arquivos tocados e churn por arquivo.
  2. git diff -w --numstat <base> <head>: o que sobra ignorando espaço dentro da linha. Se vier vazio, recuse.
  3. git diff -w <base> <head>: o patch reduzido. É aqui que a lógica aparece, porque a contagem não mostra.
  4. Opcional: git diff -w <base> <head> | grep -nE '^[-+].*(<=|<|>)' para listar linhas com operador de comparação. Em Java de verdade, < também casa com todo List<String>, então use isso para guiar a leitura, não como veredito.
  5. Se o repo tem formatter pinado, rode e veja se sobra diff (seção anterior).
  6. 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.
  • -w pode 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 como arch/{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.

javagitai-agents

// Continue.training — 次のステップ

Conhecimento só conta quando vira prática.

Volte ao artigo, execute os exemplos e compartilhe o que aprendeu.

Explorar mais artigos