All articles

// Knowledge.log — 技術記事

How to block a coding-agent PR that just reformatted the module

git diff --numstat with and without -w on a local fixture: when a coding-agent PR is pure reformat, when it hides < turning into <=, and when to reject it.

You asked the coding agent to fix a discount rule. The PR comes back with a thousand changed lines. The agent ran its own formatter over the whole module, with the indentation it liked, wrapped lines wherever it felt like and put a space inside every parenthesis. A behavior change could be sitting somewhere in that diff, and it would look exactly like every other line. Meanwhile, the branch next door touched three methods in the same file, so the merge is now one conflict per line.

You don't need a model or an external service to deal with this. Two git diff --numstat runs, one with -w and one without, plus a read of the whitespace-stripped patch, are enough to decide whether to reject the PR, send it back asking for two commits, or let it through to review. Every number below comes from a throwaway local fixture on git 2.53.0, with no network, no CI and no model API.

Don't mix this up with the -DskipTests gate, which looks for Maven flags in the command and the POM. This one looks at the size and nature of the diff.

The fixture: three commits that look like the same thing

There are three small files. Each one was first committed in a compact version and then got a "reformat" commit:

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

Only OrderTotals changes behavior. Note that you only know that because you read the commit message. In the agent's PR, the message says "style cleanup".

Case 1: DiscountPolicy, whitespace and line wrapping only

After the reformat, the file looks like this:

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 );
    }
}

Braces on their own lines, spaces inside the parentheses, and the return split across two lines. Not a single identifier or operator changed. The 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

--stat is meant for people. It draws bars, truncates long file names and adds a summary line. --numstat prints added<TAB>deleted<TAB>path with the full path. The git-diff man page says it is "designed for easier machine consumption", which is why the gate uses it.

Here's the part that trips a lot of people up: -w does not zero out this reformat. The count drops from 11/5 to 10/4 and stops there. -w (--ignore-all-space) ignores whitespace within a line, but the formatter also joined and split lines, and that changes the line count. As far as git is concerned, splitting the return into two lines is not a whitespace difference. So the rule "reject if git diff -w comes back empty" only catches the next case.

Case 2: Rates.properties, and why -b is not -w

The agent added spaces around the =:

$ 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
(empty output, exit 0)

This time -w wipes out everything, because no line was created or split. Only the whitespace inside each line changed.

If you try -b (--ignore-space-change) instead, the diff is still there:

$ 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

-b ignores changes in the amount of whitespace. Go from one space to four and it won't say a word. But loyal.rate=15 had no space at all, so whitespace was added, and -b reports the line as changed. For the gate, use -w.

Case 3: OrderTotals, what --stat can't see

This commit reformatted the file and swapped an operator:

$ 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

14/5 is the same order of magnitude as the 11/5 from the pure reformat, and -w returns the same 14/5 as the plain diff. You can't classify this PR from line counts alone. The change shows up only when you read the whitespace-stripped patch:

$ git diff -w aee2e3b 65b875b | grep -nE '^[-+].*(<=|<|>)'
11:-        if (cents < 0) {
19:+        if ( cents <= 0 )

The < became <=. Before, a zero-value request went past the if and on to the calculation. Now it returns 0 immediately. That's a boundary change, and it takes up one line of diff, exactly like a line that only gained a space inside a parenthesis. The result:

        if ( cents <= 0 )
        {
            return 0;
        }

Buried in a thousand lines of reformat, this line gets reviewed the usual way: the reviewer scrolls halfway down, decides it's all style and approves the lot.

Two metrics: files and logic

The gate works with two numbers, and both come from local git:

  • files is the number of lines in git diff --numstat <base> <head>, meaning how many files the PR touches.
  • logic is the sum of columns 1 and 2 of git diff -w --numstat <base> <head>, meaning what's left once whitespace within lines is ignored.

Don't count files with git diff --stat | wc -l. On the fixture it returned 2 for a one-file range, because the 1 file changed summary line gets counted too. With --numstat, the same range gives 1:

$ git diff --numstat da7c8be 701207f | wc -l
1

The script below uses only the Python standard library and computes both numbers. It was not part of the fixture run. The values in the table that follows were worked out from the outputs shown above.

#!/usr/bin/env python3
"""Reformat gate: files touched vs lines left over with -w.

Usage: python3 reformat_gate.py <base> <head>
Standard library only. No model, no network.
"""
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):
    # Binary files show up as "-<TAB>-<TAB>path": they count in files, not in lines.
    return sum(int(added) + int(deleted) for added, deleted, _ in rows if added != "-")


def main():
    if len(sys.argv) != 3:
        print("usage: 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("empty diff")
        return 0
    if logic == 0:
        print("REJECT: whitespace only. Nothing to review.")
        return 1
    if files >= 3 and logic / churn < 0.10:
        print("ASK FOR TWO COMMITS: repo formatter in one, logic in the other.")
        return 1
    print(f"READ THE PATCH: git diff -w {base} {head}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

Applied to the three cases:

caserangefileschurnlogicthe -w patch showsdecision
Rates.properties5bbdc1d..6e8db60140nothingreject, no discussion
DiscountPolicy.javada7c8be..701207f11614line wrapping onlyreject: module reformat
OrderTotals.javaaee2e3b..65b875b11919< became <=ask for two commits

The thresholds come from this fixture. They are not a benchmark:

  • logic == 0. The PR is a purely whitespace reformat. The script rejects it on its own.
  • logic > 0 with few files. The numbers can't decide this one. DiscountPolicy and OrderTotals land in the same range, and only reading the -w patch tells them apart. If the patch shows nothing but rewrapping, reject it and ask for the PR again without the reformat, or rebased onto a base that has already been through the repo's formatter. If logic shows up, ask for two commits.
  • files >= 3 and logic below roughly 10% of churn. This is the thousand-line PR, almost all whitespace with something real in the middle. Don't reject it for size. Require two commits before the review opens: one with only the repo formatter, the other with only the logic.

A reformat-only PR costs something even when it changes no behavior. The 16 lines in DiscountPolicy.java are 16 places where the neighboring branch can conflict, in a PR that delivers nothing.

Repository formatter vs agent formatter

The agent's formatter has taste. The repository's formatter has a version number. That difference is what makes the gate fair.

The style should be declared in the repo. EditorConfig declares the basic rules (indent_style, indent_size, max_line_length, trim_trailing_whitespace, insert_final_newline, end_of_line), and a formatter pinned in the build applies them. On Gradle, Spotless is at version 8.10.4 on the Plugin Portal (com.diffplug.spotless, published October 8, 2026), with the spotlessCheck and spotlessApply tasks. There's also spotless-maven-plugin. Look up its version on Maven Central before pinning it, because we didn't verify that number here. Spotless was not run on the fixture. The demo is git diff and nothing else.

If the base has already been through the pinned formatter, running that same formatter again changes nothing, and the file comes out with an empty git diff. So a PR that reformatted the whole module tells you the agent used its own rules (indentation, line width, where to wrap) and not the repo's. The check is cheap, though it wasn't run in this session:

./gradlew spotlessApply
git diff --exit-code -- src/main/java/com/example/pricing/

If git diff --exit-code exits non-zero after the repo's spotlessApply, whatever diff remains is formatting the repo's formatter doesn't accept. It has to come out of the PR.

All of this rests on one assumption. If the base is not formatted, the reformat may be legitimate, but it has to be done by the repo's formatter, in its own commit or its own PR, and not as a side effect of a discount task.

Checklist before you open the review

Six steps, all local git, none of them call a model:

  1. git diff --numstat <base> <head>: files touched and churn per file.
  2. git diff -w --numstat <base> <head>: what's left when whitespace within lines is ignored. If it's empty, reject.
  3. git diff -w <base> <head>: the reduced patch. This is where logic changes show up, since the counts won't reveal them.
  4. Optional: git diff -w <base> <head> | grep -nE '^[-+].*(<=|<|>)' to list lines with a comparison operator. In real Java, < also matches every List<String>, so use it to guide your reading, not as a verdict.
  5. If the repo has a pinned formatter, run it and see whether any diff remains (previous section).
  6. Decide: reject (reformat-only, or churn way out of proportion to what the task asked for), or send it back asking for two commits, one with the repo formatter and one with the logic.

Once the two commits are separate, the reviewer checks the first by running the formatter and seeing an empty diff, then reads the second line by line. The <= becomes the only thing in its commit, which is a lot harder to scroll past.

Limits

  • Counts don't classify. 11/5 of style and 14/5 with a boundary change look the same. The -w patch is what decides.
  • -w can hide things. A whitespace change inside a string literal, on a line that also changed, can disappear under -w. This fixture didn't exercise that, so treat it as a caution, not a result.
  • Parsing --numstat. Binary files come out as - -, and the script handles that. Renames come out as arch/{i386 => x86}/Makefile. If a path has unusual characters, use -z, which according to the docs keeps git from munging the pathname.
  • A fixture is not statistics. Three small files. The 10% threshold is a starting heuristic. Tune it after looking at a few real PRs from your own repo.

Next step

On the next coding-agent PR, run git diff --numstat and git diff -w --numstat against the base before you open a single file. If logic is zero, reject. If the PR touches files with far more churn than the task justifies and doesn't come split into two commits, send it back asking for the repo formatter in one commit and the logic in the other. Only then does the review start, and it starts with the -w patch.

javagitai-agents

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

Knowledge only counts when it becomes practice.

Go back to the article, run the examples, and share what you learned.

Explore more articles