coding agent 在 squash-merge 后用 HEAD~1 取基准 commit,实际指向了不相关的文档提交,导致补丁打到错误文件,空 diff 被测试误判为通过。
Agent 循环仍在沿用人类的 git 快捷方式。HEAD~1 是线性特性分支时代的习惯用法。Squash-merge 打破了这个习惯,但没有给出可见的错误。空的测试选择集随后把这次失误伪装成了成功。
关于 AI 编程质量的趋势讨论忽略了这类 bug。模型可以针对错误的父提交生成一个看似合理的 diff。工程问题出在基础契约上,而不是自动补全上。
测试用的仓库是一个一次性仓库,带有一次 squash merge。以下时间是逻辑步骤,不是实时时钟。
T0. 分支 feat/quota-header 包含两个真实提交。
T1. Review 评注通过 SHA 引用这两个提交。
T2. main 将分支 squash merge 为一个全新的提交。
T3. Agent 任务检出 main 到 squash SHA 位置。
T4. 提示词要求修复上一次变更引入的回归。
T5. Agent 运行 git rev-parse HEAD~1 作为基准。
T6. HEAD~1 解析到了一个无关的 docs 提交。
T7. Agent 修改了 docs/README.md 而不是 src/quota.py。
T8. 从 git diff origin/main 选择的测试什么都没选中。
T9. 运行时将空选择视为通过。
T10. 任务以一个无关紧要的 docs 编辑结束了任务。
Squash 提交仍然包含预期的代码变更。Agent 从未与 main 的真实 merge-base 做 diff。指向旧 SHA 的 Review 文字再也找不到对应的父提交。
几个小的默认值堆叠成了一次无声的失误。它们都不会导致编译器失败,但全部违背了基线锁定这一基本约定。
启发式父提交。 HEAD~1 不等于 merge-base(HEAD, main)。
Squash-merge。 Review SHA 在历史被重写后就失效了。
路径过滤器。 空名称列表产生空测试集。
空集成功。 退出码 0 表示"没有运行任何测试",而不是"安全"。
提示词歧义。 "上一次变更"没有指定 SHA。
无基线产物。 任务存储的是 diff,而不是父提交指针。
Git 仍然按文档描述的方式运作。是工作流谎报了意图。
下面的脚本在临时目录中构造这个陷阱。它会打印错误的父提交和真实的 merge-base。在修改任何 Agent 策略之前,先在笔记本上运行它。
#!/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
预期的 lab 输出中,错误的 diff 列出了 docs/README.md。真实的变更位于 squash 提交内部的 src/quota.py 中。HEAD~1 指向的是早于该特性的话题分支。
第二道检查应该放在 CI 里,而不是提示词里。下面的 Python gate 在两个 SHA 出现分歧时让任务失败。
# 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()
在 merge 任务期间写入 lock,而不是 agent 任务期间。merge 任务知道预期的父提交。agent 任务不知道。
#!/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.
在任何信任 Agent"修复"提交之前,先查这张表。行是检查项,列是结果。
跳过一行,squash 陷阱就会回来。保留每一行,HEAD~1 就变得无人使用了。
用三个硬工件替换父提交启发式规则。把它们放在仓库里,而不是聊天记忆里。
.git-baseline。HEAD~1 或 HEAD^ 做 diff。一个 Agent 运行时策略的最小片段如下。
# 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
运行时还应该拒绝空的测试计划。标记为 fix 的任务如果收集到零个测试,就是一次失败的任务。不要允许 pytest、jest 或 go test 在空集上以退出码 0 退出。
# 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
回放和 lock 文件一样重要。在干净的克隆上第二次运行应该复现同样的失误。免费本地运行时正是在这里体现其价值的。
披露:本文是 MonkeyCode 产品推广的一部分。MonkeyCode 的免费模型访问和免费服务器选项可以托管那种干净的回放。但该 fixture 仍然可以在没有该产品的情况下运行。lock 文件才是真正的控制手段。将该产品仅用作备用克隆,而不是 git 真相的来源。
正确的父提交是受保护分支的 merge-base。在 squash 之后,这个值通常等于 HEAD 本身。后续修复然后对工作代码与 lock 做 diff,而不是对 HEAD~1。
base=$(cat .git-baseline)
git diff --name-only "$base"
git diff "$base" -- src/quota.py
如果这些命令显示 src/quota.py,Agent 可以编辑该文件。如果什么都没有显示,说明任务作用域错误,必须停止。停止是有效的故障响应。交付 docs 不是。
lock 文件在未记录 force-push 的情况下无法存活。重写 main 的团队必须在同一任务中重写 .git-baseline。八叉合并(octopus merge)会使 merge-base 在没有额外固定的情况下变得模糊。单仓 repo 需要路径范围的 lock,否则无关的包会发生碰撞。本 fixture 忽略了子模块、工作树和部分克隆。
Python gate 不能证明行为正确性。它只证明 Agent 瞄准了预期的代码树。错误的代码树补丁仍然可以编译。这就是最初的失败。
不要将这个 lock 应用到从不 squash 的仓库。线性 merge 提交在许多情况下已经保持了 HEAD~1 的有效性。不要把它作为安全补丁审查的替代品。不要把免费共享运行时当作秘密存储或生产副本。如果测试套件故意稀疏,不要跳过空测试失败检查。
单提交的兴趣仓库从这个额外文件中获益甚微。无论如何还是要保留启发式规则禁用。它便宜且本地化。
Squash-merge 删除了 Agent 想要指定的父提交。HEAD~1 然后指向看起来仍然合法的剩余历史。路径过滤测试祝福了空的 diff。真实的 bug 仍然被合并进去了。
固定 merge-base。禁止启发式父提交。让空的修复运行失败。这三条规则比任何一个模型、服务器或提示词模板都活得久。当策略改变时回放 fixture。不要信任记忆。