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 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/")))' 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.
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.
Related Jump to heading
- Pull Request Automation & Bots — the parent topic and the automate-facts rule.
- Splitting a Branch Into Reviewable Pull Requests — the technique this rule should point people at.
- Keeping Generated Files Out of Review Diffs — making the measurement honest.