Enforcing pull request size limits Jump to heading

Review quality falls off a cliff somewhere around a few hundred changed lines. Past that, reviewers stop reading and start scanning, approval rates go up while defect detection goes down, and the review becomes a formality that costs a day of latency. A size limit is an attempt to hold the line — but a hard limit, naively measured, mostly teaches people to commit generated files separately and pad their diffs. This recipe measures the right thing and applies it as pressure rather than as a gate, within pull request automation and bots.

When to use this approach Jump to heading

  • Large pull requests are approved faster than small ones, which is the clearest symptom.
  • Review comments cluster in the first few files and thin out afterwards.
  • Authors routinely bundle a refactor with a feature because splitting is inconvenient.
  • You have somewhere to point people for the splitting technique.
  • If your team already ships in small increments, a limit adds process without changing behaviour.

Step 1 — Measure reviewable lines, not diff lines Jump to heading

Lockfiles, generated clients, snapshots and vendored code inflate a diff without adding anything to read. Counting them makes the metric meaningless and the rule unfair.

# Mark what is generated so both review and measurement agree
cat >> .gitattributes <<'ATTR'
*.lock           linguist-generated=true
**/generated/**  linguist-generated=true
*.snap           linguist-generated=true
vendor/**        linguist-generated=true
ATTR
# Reviewable lines in the current change
base=$(git merge-base origin/main HEAD)
git diff --numstat "$base"...HEAD | while read -r add del path; do
  git check-attr linguist-generated -- "$path" | grep -q ': set$' && continue
  echo $((add + del))
done | awk '{s+=$1} END {print s " reviewable lines"}'
# Compare with the raw number to see how much noise you were counting
git diff --shortstat "$base"...HEAD
Raw diff against reviewable lines on four real changesA change that regenerates an API client or updates a lockfile can show thousands of changed lines while asking a reviewer to read a few dozen. Measuring reviewable lines only is what makes a size rule defensible.lines in the diff, raw versus reviewableclient regen — raw3100client regen — reviewable40feature — raw380feature — reviewable340the first pair is why a raw-line rule gets ignored within a fortnight

Step 2 — Pick thresholds from your own history Jump to heading

Use the point where your review behaviour visibly changes, not a number from an article.

# Comments per reviewable line, bucketed by pull request size
gh pr list --state merged --limit 200 --json number,additions,deletions,comments \
  --jq '.[] | {size: (.additions + .deletions), comments: (.comments | length)}' \
  | jq -s 'group_by(.size / 100 | floor)[]
           | {bucket: ((.[0].size / 100 | floor) * 100),
              prs: length,
              avg_comments: ((map(.comments) | add) / length)}'

The bucket where average comments stop rising with size is your practical limit: past it, reviewers are not reading proportionally more.

Step 3 — Warn, label and explain; do not block Jump to heading

A blocked pull request at the end of a day’s work produces a split that is mechanical rather than logical — three commits chopped by line count, which is worse to review than the original.

# .github/workflows/size.yml
name: size
on: [pull_request]
permissions:
  pull-requests: write
jobs:
  measure:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - id: count
        run: |
          base=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
          total=0
          while read -r add del path; do
            git check-attr linguist-generated -- "$path" | grep -q ': set$' && continue
            total=$((total + add + del))
          done < <(git diff --numstat "$base"...HEAD)
          echo "lines=$total" >> "$GITHUB_OUTPUT"
          case $total in
            [0-9]|[0-9][0-9]|[0-9][0-9][0-9]) label="size/S" ;;
            *) label="size/L" ;;
          esac
          echo "label=$label" >> "$GITHUB_OUTPUT"
      - run: gh pr edit "$PR" --add-label "${{ steps.count.outputs.label }}"
        env: { GH_TOKEN: "${{ github.token }}", PR: "${{ github.event.number }}" }
# Verification: the label matches the measured count
gh pr view "$PR" --json labels --jq '[.labels[].name] | map(select(startswith("size/")))'
What to do when a change is genuinely largeA large change made of independent steps can be split into a sequence of pull requests. A mechanical change such as a rename is large but trivial to review and should be labelled as such. A large change that is one indivisible decision needs a longer review slot, not a split.Why is this change large?independent stepsSplit itstack the partsmechanical sweepLabel it mechanicalreviewed differentlyone indivisible changeBook a review slotpairing beats scrollinga size limit that ignores the middle and right branches punishes correct work

Step 4 — Make splitting genuinely easy Jump to heading

Pressure without a path produces resentment. The technique for turning one branch into a reviewable sequence is worth linking directly from the bot’s message.

# Split the current branch at a logical boundary
git log --oneline origin/main..HEAD          # find the seam
git switch -c part-1 origin/main
git cherry-pick <first-commit>..<seam-commit>
git push -u origin part-1                    # open this one first

The full procedure, including how to keep the second part reviewable while the first is in flight, is in splitting a branch into reviewable pull requests and stacked pull requests without a dedicated tool.

Comments per hundred reviewable lines, by change sizeSmall changes attract dense feedback. As size grows, comments per line fall steeply — reviewers are reading a smaller and smaller proportion of what they approve. The bucket where the curve flattens is where a size rule should point.review comments per 100 reviewable linesunder 100 lines6.1100-300 lines3.4300-800 lines1.2over 800 lines0.3approval rates stay flat across all four bars — only the reading changes

Step 5 — Watch review quality, not compliance Jump to heading

The point of the rule is better review. Measure that, or you will optimise a proxy.

# Proportion of pull requests approved with no comments, by size label
gh pr list --state merged --limit 200 --json number,labels,reviews \
  --jq '[.[] | {size: ([.labels[].name] | map(select(startswith("size/"))) | first // "size/?"),
                silent: ([.reviews[] | select(.state=="APPROVED")] | length > 0
                         and (.reviews | map(.body) | join("") | length == 0))}]
        | group_by(.size)[] | {size: .[0].size, silent_rate: ((map(select(.silent)) | length) / length)}'

A high silent-approval rate in the large bucket means the limit is not working as guidance. A high rate everywhere means the problem is review culture, and no size rule will fix it.

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Should the limit ever be a hard gate? Jump to heading

Rarely, and only on paths where large changes are genuinely dangerous — a security boundary, a migration directory. A blanket hard gate converts a guidance problem into a compliance problem, and the compliance behaviour it produces is padding and splitting along the wrong seams.

What about a large change that is one rename? Jump to heading

Label it and move on. A mechanical sweep across two hundred files is reviewed by checking the mechanism once and spot-checking the result, which is a different activity from reading a feature. Making room for that distinction is what keeps the rule credible.

Does counting deletions unfairly penalise cleanup? Jump to heading

Somewhat, and most teams weight deletions lower for that reason — removing four hundred lines is usually easier to verify than adding forty. Halving the weight of deletions is a reasonable adjustment, provided it is written down rather than discovered.