DEV Community

Morgan Xu
Morgan Xu

Posted on

Postmortem: Squash-Merge Made HEAD~1 Target Unrelated Code

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.

  1. T0. Branch feat/quota-header holds two real commits.
  2. T1. Review comments refer to those two commits by SHA.
  3. T2. main squash-merges the branch into one new commit.
  4. T3. The agent job checks out main at the squash SHA.
  5. T4. The prompt says to fix the regression from the last change.
  6. T5. The agent runs git rev-parse HEAD~1 as the baseline.
  7. T6. HEAD~1 resolves to an unrelated docs commit.
  8. T7. The agent patches docs/README.md instead of src/quota.py.
  9. T8. Tests selected from git diff origin/main select nothing.
  10. T9. The runner treats an empty selection as a pass.
  11. 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~1 is not merge-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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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.

  1. Pin the merge-base in .git-baseline at squash time.
  2. Fail CI when the agent baseline disagrees with that file.
  3. Fail CI when a "fix" task selects zero tests.
  4. Name SHAs in prompts. Do not say "the last change."
  5. Diff against the lock, never against HEAD~1 or HEAD^.

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)