Your first AI pull request should lose to recent merges.
Measure main before any agent edits a file.
An agent will happily touch twenty unrelated paths.
Your teammates will not review that on day one.
The last five merges already show house style.
The rule
Treat recent merges as a hard size contract.
Your first PR must stay under that contract.
If the agent exceeds it, you stop and split.
This check is not a vague vibe test.
It is a git measurement you can rerun.
Finish it in hour one, before the first prompt.
Why recent merges beat model confidence
The model did not join your onboarding call.
It did not watch your team's review comments.
It only sees files and one ticket sentence.
Juniors fail here in a predictable pattern.
They paste the whole ticket into an agent.
The agent then "finishes" work across six packages.
Reviewers reject that PR for scope, not bugs.
You burn a day defending generated noise.
Rollback hurts because the diff has no center.
Recent merges tell the opposite, quieter story.
They show typical file counts and path prefixes.
They show whether tests land beside production code.
Copy those bounds instead of the agent's plan.
Your first week is for a reviewable shape.
Heroic generated patches can wait until week two.
What you capture in hour one
You need four facts, not an architecture tour.
- A baseline tag on a clean tree.
- Median files changed across five merges.
- Median insertions and deletions from those merges.
- Path prefixes that actually appear in those merges.
Write the facts to a file you control.
The agent never edits that budget file.
You own the numbers and the rollback tag.
Artifact: derive a first-PR budget from git
The scripts below are a proposed local workflow.
Run them on a throwaway clone first.
Read each command before you execute it.
Step 1: Tag a baseline
Start from a clean worktree on the default branch.
git fetch origin
git switch -C onboarding origin/main
git status --porcelain
test -z "$(git status --porcelain)" || exit 1
git tag -f onboarding-baseline
git rev-parse --short onboarding-baseline
You now have a named tree you can restore.
Hour one is incomplete without that tag.
Do not start the agent before the tag exists.
Step 2: Sample five first-parent commits
Use merge commits when the team actually merges.
Fall back to first-parent history when they squash.
# Proposed: inspect what you are about to sample
git log --first-parent --merges origin/main -n 5 --pretty=format:'%h %s'
# If that list is empty, sample regular commits
git log --first-parent origin/main -n 5 --pretty=format:'%h %s'
Look at the subjects before you trust the sample.
Drop a release train or a repo-wide rename.
Keep five boring feature merges if you can.
Step 3: Write the budget file
Save this helper as scripts/first_pr_budget.py.
It is labeled proposed because you must review it.
It only reads git and prints JSON to stdout.
#!/usr/bin/env python3
"""Proposed: median first-PR budget from first-parent commits."""
from __future__ import annotations
import json
import subprocess
import sys
from collections import Counter
from pathlib import Path
IGNORE_SUFFIXES = (".lock", "-lock.json", ".sum")
IGNORE_NAMES = {"package-lock.json", "yarn.lock", "pnpm-lock.yaml", "Cargo.lock"}
def git(*args: str) -> str:
return subprocess.check_output(["git", *args], text=True)
def median(nums: list[int]) -> int:
if not nums:
return 0
s = sorted(nums)
n = len(s)
if n % 2:
return s[n // 2]
return (s[n // 2 - 1] + s[n // 2]) // 2
def prefixes_for(path: str) -> str:
parts = Path(path).parts
if len(parts) >= 2:
return f"{parts[0]}/{parts[1]}/"
return path
def numstat(hash_: str) -> tuple[int, int, int, list[str]]:
files = ins = dels = 0
prefixes: list[str] = []
for line in git("show", "--numstat", "--format=", hash_).splitlines():
parts = line.split("\t")
if len(parts) != 3:
continue
added, removed, path = parts
name = Path(path).name
if name in IGNORE_NAMES or path.endswith(IGNORE_SUFFIXES):
continue
if added == "-" or removed == "-":
continue
files += 1
ins += int(added)
dels += int(removed)
prefixes.append(prefixes_for(path))
return files, ins, dels, prefixes
def main() -> None:
range_ = sys.argv[1] if len(sys.argv) > 1 else "origin/main"
n = int(sys.argv[2]) if len(sys.argv) > 2 else 5
hashes = [
h for h in git("log", "--first-parent", range_, f"-n{n}", "--pretty=%H").split()
if h
]
file_counts: list[int] = []
insertions: list[int] = []
deletions: list[int] = []
prefix_counter: Counter[str] = Counter()
for hash_ in hashes:
files, ins, dels, prefixes = numstat(hash_)
file_counts.append(files)
insertions.append(ins)
deletions.append(dels)
prefix_counter.update(prefixes)
top_prefixes = [p for p, _ in prefix_counter.most_common(3)]
print(
json.dumps(
{
"range": range_,
"sample_size": len(hashes),
"max_files": median(file_counts),
"max_insertions": median(insertions),
"max_deletions": median(deletions),
"path_prefixes": top_prefixes,
"baseline_tag": "onboarding-baseline",
},
indent=2,
)
)
if __name__ == "__main__":
main()
Run it against origin/main and read the result aloud.
python3 scripts/first_pr_budget.py origin/main 5 > first-pr-budget.json
cat first-pr-budget.json
If max_files looks like a migration, resample.
Remove that hash from your mental sample set.
Rerun until the median looks like normal team work.
Step 4: Enforce the budget before you push
Save this check as scripts/check_first_pr_budget.py.
#!/usr/bin/env python3
"""Proposed: compare worktree diff against first-pr-budget.json."""
from __future__ import annotations
import json
import subprocess
import sys
def git(*args: str) -> str:
return subprocess.check_output(["git", *args], text=True)
def main() -> None:
budget_path = sys.argv[1] if len(sys.argv) > 1 else "first-pr-budget.json"
base = sys.argv[2] if len(sys.argv) > 2 else "onboarding-baseline"
budget = json.loads(open(budget_path, encoding="utf-8").read())
names = [n for n in git("diff", "--name-only", base).splitlines() if n]
ins = dels = 0
for line in git("diff", "--numstat", base).splitlines():
parts = line.split("\t")
if len(parts) != 3 or parts[0] == "-" or parts[1] == "-":
continue
ins += int(parts[0])
dels += int(parts[1])
failed = False
if len(names) > budget["max_files"]:
print(f"FAIL files {len(names)} > {budget['max_files']}")
failed = True
if ins > budget["max_insertions"]:
print(f"FAIL insertions {ins} > {budget['max_insertions']}")
failed = True
if dels > budget["max_deletions"]:
print(f"FAIL deletions {dels} > {budget['max_deletions']}")
failed = True
allowed = budget.get("path_prefixes") or []
if allowed:
for name in names:
if not any(name.startswith(prefix) for prefix in allowed):
print(f"FAIL path outside prefixes: {name}")
failed = True
if failed:
sys.exit(1)
print("PASS first-PR budget")
if __name__ == "__main__":
main()
Run the check every time the PR feels finished.
python3 scripts/check_first_pr_budget.py first-pr-budget.json onboarding-baseline
A failed check is a split, not a debate.
Move leftover files onto a second branch.
Your first PR stays inside a reviewable shape.
Do not encode prefixes until they look stable.
Wrong prefixes will block legitimate test files.
Keep that rule in comments for the first day.
Decision table for hour one
Use this table before you type a prompt.
| Signal from the last five merges | First PR move | First rollback move |
|---|---|---|
| Median files are 1–4 | Keep the agent in one package | Restore the baseline tag if extra packages appear |
| Median files are 5–12 | Cap the ticket to one behavior | Revert path by path from the tag |
| One sample is a lockfile bump | Drop that sample and rerun | Do not revert unrelated lockfiles |
| Prefixes disagree across merges | Skip prefix fail; keep size caps | Restore only files you touched |
| Sample includes CI or infra | Forbid those prefixes in the prompt | Checkout CI files from the tag |
Write your chosen row into the PR body.
Reviewers then see the contract you copied.
They are not guessing why the patch is small.
First PR: stay inside the budget
You now have numbers from real history.
Use them as the only size argument.
Tell the agent the budget inside the prompt.
Ticket: PAY-1843 add retry on 429 for the billing client.
Max files: 4.
Max insertions: 80.
Max deletions: 20.
Allowed prefixes: src/billing/ tests/billing/
Do not edit CI, lockfiles, or docs.
Stop when the budget would break.
Return the file list before you edit.
If the agent returns twelve files, you stop.
You revert the extra paths yourself.
Then you rerun the budget check until it passes.
Name the PR after the ticket, not the model.
Keep the description inside the measured bounds.
Reviewers should recognize a familiar team shape.
First rollback: reset to the baseline tag
Your rollback target is the hour-one tag.
It is not a guessed reset from memory.
If the branch is still local and unpushed, restore it.
git switch -C recovery onboarding-baseline
git status --porcelain
git diff --stat onboarding-baseline
If you already pushed the first PR branch, isolate the undo.
git fetch origin
git switch your-first-pr-branch
git checkout onboarding-baseline -- src/billing/
git status --short
Practice the unpushed path once on a junk branch.
Confirm git diff onboarding-baseline is empty after recovery.
That empty diff is the whole point of hour one.
Do not delete the tag until the first PR merges.
The tag is your personal undo, not a release.
Teammates do not need to adopt the tag name.
Where a free model still helps
Git will not write the paragraph reviewers want.
You still need a one-page contract from the JSON.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Paste first-pr-budget.json and the five merge subjects into a model.
Ask for a short first-PR contract in plain English.
MonkeyCode's free model access and free server option can draft that note while the scripts stay local.
The model may summarize. It may not raise your budget.
Limitations
Medians lie when history is mixed.
A release week will inflate every number.
A docs-only week will shrink them too far.
Lockfiles and generated code wreck raw numstat.
Filter them before you trust max_insertions.
If you cannot name those files, delay the agent.
This workflow does not prove the change is correct.
It only caps blast radius for a junior first PR.
Tests, types, and review still sit on you.
Squash culture hides real merge boundaries.
First-parent sampling is an approximation, not truth.
Ask a teammate which five commits look normal.
Prefix guards fail on monorepos with shared tests.
Size caps still help when prefixes do not.
Do not pretend the JSON is a complete policy.
Who should skip this
Skip this if you already own the module.
Skip this if the ticket is an incident hotfix.
Skip this if main has fewer than five useful commits.
Skip this if your change is a one-line config.
A budget check would only add ceremony there.
Keep the baseline tag anyway; it is cheap.
Close
Hour one is measurement, not generation.
The first PR is a capped, familiar diff.
The first rollback is a tag, not a story.
Copy recent merges on your first day.
Leave the wide generated patch for later.
Top comments (1)
Splitting the 429 retry example just to stay below 80 insertions could make the first PR harder to review if its tests land separately. I'd use the median from five recent merges to flag scope for discussion, while keeping the behavior and its tests together. Unrelated cleanup is a good second PR; the evidence that the retry works belongs with the change.