The durable fix is a locked merge-base, not HEAD~1.
An agent that infers the review parent from git heuristics can patch the wrong tree.
This reconstructed incident shows how that miss ships a green no-op.
What Failed
A coding agent received a follow-up task after a squash-merge.
It treated HEAD~1 as the previous reviewed parent.
That parent was an unrelated docs commit on main.
The generated patch compiled and the path-filtered suite exited zero.
The wrong file received the change. The real regression stayed open.
This write-up is a lab reconstruction, not a named outage.
Commands below are local and unlabeled as production evidence.
No model names, quotas, or hardware claims appear here.
Why This Incident Matters Now
Agent loops still borrow human git shortcuts.
HEAD~1 is a habit from linear feature branches.
Squash-merge breaks that habit without a visible error.
Empty test selection then disguises the miss as success.
Trend talk about AI coding quality misses this class of bug.
The model can emit a plausible diff against the wrong parent.
Engineering fails at the baseline contract, not the autocomplete.
Timeline
The fixture uses a disposable repo and a squash merge.
Times are logical steps, not a live clock.
-
T0. Branch
feat/quota-headerholds two real commits. - T1. Review comments refer to those two commits by SHA.
-
T2.
mainsquash-merges the branch into one new commit. -
T3. The agent job checks out
mainat the squash SHA. - T4. The prompt says to fix the regression from the last change.
-
T5. The agent runs
git rev-parse HEAD~1as the baseline. -
T6.
HEAD~1resolves to an unrelated docs commit. -
T7. The agent patches
docs/README.mdinstead ofsrc/quota.py. -
T8. Tests selected from
git diff origin/mainselect nothing. - T9. The runner treats an empty selection as a pass.
- T10. The job closes the task with a no-op docs edit.
The squash commit still contains the intended code change.
The agent never diffed against the true merge-base with main.
Review text that named old SHAs no longer mapped to parents.
Contributing Factors
Several small defaults stacked into one silent miss.
None of them fail a compiler. All of them fail a baseline lock.
-
Heuristic parent.
HEAD~1is notmerge-base(HEAD, main). - Squash-merge. Review SHAs die when history is rewritten.
- Path filters. An empty name list yields an empty test set.
- Success-on-empty. Exit code 0 means "nothing ran," not "safe."
- Prompt ambiguity. "Last change" does not name a SHA.
- No baseline artifact. The job stored a diff, not a parent pin.
Git still behaved as documented. The workflow lied about intent.
Reproducible Artifact
The script below builds the trap in a temp directory.
It prints both the wrong parent and the true merge-base.
Run it on a laptop before changing any agent policy.
#!/usr/bin/env bash
# Label: lab fixture, not a production transcript.
set -euo pipefail
root=$(mktemp -d)
trap 'rm -rf "$root"' EXIT
cd "$root"
git init -q -b main
git config user.email lab@example.test
git config user.name lab
mkdir -p src docs
echo 'def quota(): return 1' > src/quota.py
echo '# docs' > docs/README.md
git add src docs && git commit -qm 'docs: seed readme and quota stub'
# Unrelated docs commit that will become HEAD~1 after squash.
echo '# docs v2' > docs/README.md
git add docs && git commit -qm 'docs: tweak readme wording'
git checkout -qb feat/quota-header
printf '%s\n' 'def quota():' ' return 2' > src/quota.py
git add src && git commit -qm 'feat: raise quota return'
printf '%s\n' 'def quota():' ' return 3' > src/quota.py
git add src && git commit -qm 'fix: quota off-by-one'
git checkout -q main
git merge --squash feat/quota-header
git commit -qm 'feat: quota header (squashed)'
echo "HEAD $(git rev-parse --short HEAD)"
echo "HEAD~1 $(git rev-parse --short HEAD~1) # WRONG baseline"
echo "merge-base $(git merge-base HEAD main)" # same as HEAD here
# Simulate the follow-up checkout that agents often do.
git checkout -qb agent/followup
wrong=$(git rev-parse HEAD~1)
right=$(git merge-base --fork-point main HEAD || git merge-base main HEAD)
echo "agent_used $(git rev-parse --short "$wrong")"
echo "should_use $(git rev-parse --short "$right")"
git diff --name-only "$wrong" HEAD || true
Expected lab output names docs/README.md in the wrong diff.
The true change lives in src/quota.py inside the squash commit.
HEAD~1 points at the docs tweak that predates the feature.
A second check belongs in CI, not in a prompt.
The Python gate below fails the job when the two SHAs diverge.
# Label: proposed gate. Pin versions in the real repo.
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
def git(*args: str) -> str:
out = subprocess.check_output(["git", *args], text=True)
return out.strip()
def load_lock(path: Path) -> str:
if not path.exists():
raise SystemExit("missing .git-baseline lock")
return path.read_text().strip()
def main() -> None:
head = git("rev-parse", "HEAD")
locked = load_lock(Path(".git-baseline"))
merge_base = git("merge-base", "HEAD", "origin/main")
head_parent = git("rev-parse", "HEAD~1")
if locked != merge_base:
print("baseline lock != merge-base", locked, merge_base)
sys.exit(2)
if head_parent == locked and head != merge_base:
# Linear history can match; squash follow-ups must not trust it.
pass
names = git("diff", "--name-only", locked, head)
if not names:
print("empty diff against locked merge-base")
sys.exit(3)
print("ok", head[:12], locked[:12])
if __name__ == "__main__":
main()
Write the lock during the merge job, not during the agent job.
The merge job knows the intended parent. The agent job does not.
#!/usr/bin/env bash
set -euo pipefail
base=$(git merge-base HEAD origin/main)
printf '%s\n' "$base" > .git-baseline
git add .git-baseline
# Commit this file on the follow-up branch before the agent starts.
Decision Table
Use the table before trusting any agent "fix" commit.
Rows are checks. Columns are outcomes.
| Check | Pass means | Fail means |
|---|---|---|
.git-baseline exists |
Parent was recorded at merge time | Agent may invent HEAD~1
|
lock == merge-base(HEAD, origin/main)
|
Diff is against the branch point | History rewrite drifted |
git diff --name-only is non-empty |
The task touches files | Success-on-empty is likely |
| selected tests >= 1 | Suite actually ran | Path filter hid the miss |
| patched paths intersect failing tests | Fix is coupled to evidence | Docs-only no-op |
Skip a row and the squash trap returns.
Keep every row and HEAD~1 becomes unused.
Durable Fix
Replace parent heuristics with three hard artifacts.
Keep them in the repo, not in chat memory.
-
Pin the merge-base in
.git-baselineat squash time. - Fail CI when the agent baseline disagrees with that file.
- Fail CI when a "fix" task selects zero tests.
- Name SHAs in prompts. Do not say "the last change."
-
Diff against the lock, never against
HEAD~1orHEAD^.
A minimal policy snippet for an agent runner looks like this.
# Label: proposed runner policy, not a vendor API.
FORBIDDEN_BASELINES = ("HEAD~1", "HEAD^", "HEAD~", "@~")
def resolve_baseline(raw: str, lock: str) -> str:
if raw.strip() in FORBIDDEN_BASELINES:
raise ValueError("heuristic parent is forbidden")
if raw.strip() != lock:
raise ValueError("baseline must equal .git-baseline")
return lock
The runner should also reject empty test plans.
A task labeled fix with zero collected tests is a failed job.
Do not allow pytest, jest, or go test to exit 0 on an empty set.
# pytest: fail if no tests were collected
pytest -q --maxfail=1
# wrap collection
count=$(pytest --collect-only -q | tail -n 1)
echo "$count" | grep -q '^no tests collected' && exit 4 || true
Replay matters as much as the lock file.
A second pass on a clean clone should reproduce the same miss.
That is where a free local runner earns its place in the method.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode's free model access and free server option can host that clean replay.
The fixture still runs without that product. The lock file is the actual control.
Use the product only as a spare clone, not as a source of git truth.
What The Agent Should Have Done
The correct parent is the merge-base with the protected branch.
After a squash, that value often equals HEAD itself.
A follow-up fix then diffs working code against the lock, not HEAD~1.
base=$(cat .git-baseline)
git diff --name-only "$base"
git diff "$base" -- src/quota.py
If those commands show src/quota.py, the agent may edit that file.
If they show nothing, the task is mis-scoped and must stop.
Stopping is a valid incident response. Shipping docs is not.
Limitations
The lock does not survive an unrecorded force-push.
Teams that rebase main must rewrite .git-baseline in the same job.
Octopus merges make merge-base ambiguous without extra pins.
Monorepos need path-scoped locks, or unrelated packages will collide.
This fixture ignores submodules, worktrees, and partial clones.
The Python gate does not prove behavioral correctness.
It only proves the agent aimed at the intended tree.
Wrong-tree patches can still compile. That is the original failure.
Who Should Not Use This Approach
Do not apply this lock to repos that never squash.
Linear merge commits already keep HEAD~1 meaningful in many cases.
Do not use it as a substitute for review on security patches.
Do not treat a free shared runner as a secret store or prod replica.
Do not skip the empty-test failure if the suite is intentionally sparse.
Single-commit hobby repos gain little from the extra file.
Keep the heuristic ban anyway. It is cheap and local.
Close
Squash-merge deletes the parent the agent wanted to name.
HEAD~1 then points at leftover history that still looks legitimate.
Path-filtered tests bless the empty diff. The real bug stays merged.
Pin the merge-base. Forbid heuristic parents. Fail empty fix runs.
Those three rules outlive any one model, server, or prompt template.
Replay the fixture when the policy changes. Do not trust memory.
Top comments (0)