当你知道"线上坏了",却不知道是过去几百次提交里的哪一次把代码改坏了,git bisect 是标准答案——但它需要一个能自动判定"这个版本好还是坏"的脚本。传统做法是手写一个复现用例,可一旦回归是"输出语气不对""布局错位""某个边界语义错了"这类难以量化的 bug,脚本根本写不出来。本教程教你把 Claude Code 的 -p 无头模式塞进 git bisect run,让大模型充当语义法官:每个被二分到的提交,自动读代码、按 bug 描述判定好坏、吐出结构化结论,最终无人值守地定位到那一个"罪魁祸首"提交。适合已熟悉 Git 基本操作、想把 Claude Code 接进 CI 与自动化排查流程的后端/前端工程师与 SRE。
git,熟悉 commit / log / checkout 基本命令,对 git bisect 有概念性了解即可(本教程会带一遍)。claude 命令可用,已完成登录或配置了 ANTHROPIC_API_KEY。用 claude --version 确认。jq 解析 JSON 输出,jq --version 能跑即可;Windows 用户建议在 Git Bash 或 WSL 下操作。log₂(N) 次模型调用。100 个提交区间约 7 次调用、1000 个约 10 次,成本可控;但每次调用都会把当前代码读进上下文,大型仓库建议先用 --add-dir 或子目录限定范围。git bisect 在好提交(good)和坏提交(bad)之间做二分搜索:每次 checkout 到中点,由你(或一个脚本)告诉它"这个版本是好是坏",它再砍一半,直到锁定第一个引入回归的提交。git bisect run <script> 把"告诉它好坏"这一步交给脚本——脚本的退出码就是判词:
| 脚本退出码 | 含义 |
|---|---|
0 |
当前提交是 good(没有该 bug) |
1–124 |
当前提交是 bad(有该 bug) |
125 |
跳过此提交(编不过、跑不了,无法判定) |
128 及以上 |
中止整个 bisect |
关键洞察:脚本内部用什么手段得出退出码,git 完全不关心。 传统脚本跑一个测试套件,而我们要做的,是让脚本调用 claude -p——把"读代码 + 对照 bug 描述 + 下判词"这件事交给模型。这就是"语义法官"模式。
它的价值在"难量化回归"上才真正凸显:
assert add(2,3)==5):直接写测试脚本,不必麻烦模型。-p 法官的用武之地。bisect 的输入是两个锚点。先确认它们:
# 当前 HEAD 已知是坏的(线上故障复现于此)
git checkout main
BAD=$(git rev-parse HEAD)
# 翻到一个你确信"还没坏"的旧版本,比如上个发布 tag
GOOD=v1.4.0
git log --oneline $GOOD..$BAD | wc -l # 看看要二分的区间有多大
接着准备一份案情描述,放进 bug-report.md。写得越具体、越可验证,法官判得越准:
# 回归案情
## 现象
调用 `grade.letter_grade(90)` 期望返回 "A",但在 HEAD 上返回了 "B"。
score 恰好等于分级阈值(90/80/70/60)时,等级偏低一档。
## 判定标准(法官请据此判 good/bad)
- 阅读 src/grade.py 中的 letter_grade 函数。
- 若阈值的比较运算符是 `>=`(含等号),判 **good**。
- 若任一阈值写成 `>`(不含等号),判 **bad**。
- 只看 letter_grade 的阈值比较,不要被其它改动干扰。
常见问题:区间太大、提交太多怎么办? 先用
git bisect visualize或git log --oneline看一眼,必要时用git bisect skip跳过明显无关的合并提交。区间每翻倍,调用次数只多一次,不必过度收窄。
新建 judge.sh,它的职责是:读当前 checkout 出来的代码 → 调 claude -p → 解析结论 → 退出 0/1/125。
#!/usr/bin/env bash
# judge.sh — 让 Claude 当语义法官,判定当前提交是 good 还是 bad
set -uo pipefail
REPORT="bug-report.md"
# --bare:CI/脚本推荐模式,跳过本地 hooks/skills/MCP/CLAUDE.md,
# 保证每台机器结果一致。认证必须来自 ANTHROPIC_API_KEY。
# --allowedTools "Read":法官只需读代码,不给写权限,避免误改文件污染工作区。
# --output-format json + --json-schema:强制模型返回固定结构,便于 jq 解析。
OUT=$(claude --bare -p \
--allowedTools "Read" \
--output-format json \
--json-schema '{
"type":"object",
"properties":{
"verdict":{"type":"string","enum":["good","bad"]},
"reason":{"type":"string"}
},
"required":["verdict","reason"]
}' \
"你是回归判定法官。请先用 Read 工具读取 src/grade.py,然后阅读 $(cat $REPORT) 中的判定标准。严格按照标准输出 verdict:阈值含等号=good,不含等号=bad。不要被其它改动干扰。" \
2>/dev/null)
# 模型调用本身失败(额度、网络)→ 退出 125 跳过此提交,而不是误判
if [ -z "$OUT" ]; then
echo "judge: empty response, skipping this commit" >&2
exit 125
fi
VERDICT=$(echo "$OUT" | jq -r '.structured_output.verdict // empty' 2>/dev/null)
REASON=$(echo "$OUT" | jq -r '.structured_output.reason // "(no reason)"' 2>/dev/null)
case "$VERDICT" in
good) echo "judge: GOOD — $REASON"; exit 0 ;;
bad) echo "judge: BAD — $REASON"; exit 1 ;;
*) echo "judge: AMBIGUOUS ($VERDICT), skipping — $REASON" >&2; exit 125 ;;
esac
几个关键设计:
--bare 是脚本场景的推荐模式。 官方文档明确:它会跳过自动发现的 hooks、skills、plugins、MCP 服务器和 CLAUDE.md,只认你显式传入的 flag。这意味着你队友 ~/.claude 里某个奇怪的 hook 不会污染判定结果,CI 上每台机器行为一致。--bare 将成为 -p 的未来默认值。--allowedTools "Read" 用的是权限规则语法,只给只读权限——法官不能改代码,否则它在某个提交上"顺手修一下"会毁掉工作区,让 bisect 的 checkout 失败。--json-schema 强制结构化输出,结论落在 .structured_output.verdict。这是让"模型自由文本"变成"可解析判词"的关键,比正则匹配可靠得多。exit 125(跳过),而不是 exit 1。 限流或网络抖动不该被当成"这个提交有 bug"。125 让 bisect 跳过当前点、继续推进,事后你可以对这些 skip 点单独复查。常见问题:法官判得不稳,同一段代码时好时坏怎么办? 三招:(a) 把判定标准写到"非黑即白"——明确给出"看到 X 就是 good,看到 Y 就是 bad",避免开放式判断;(b) 用
--append-system-prompt "只依据判定标准,不要引入外部知识";(c) 把要判定的文件缩小到子目录,减少噪声。若仍不稳,可在脚本里对该提交连判 3 次取多数票。
赋予执行权限,启动二分:
chmod +x judge.sh
git bisect start
git bisect bad "$BAD"
git bisect good "$GOOD"
git bisect run ./judge.sh
之后你就可以去倒杯咖啡。git 会反复 checkout、调用 judge.sh、根据退出码砍半。某次运行的输出大致长这样:
running ./judge.sh
judge: GOOD — letter_grade uses >= for all thresholds
Bisecting: 37 revisions left to test after this (roughly 5 steps)
[abc1234] refactor grade thresholds
running ./judge.sh
judge: BAD — line 12 uses > 90 instead of >= 90
Bisecting: 18 revisions left to test after this (roughly 4 steps)
...
def5678 is the first bad commit
commit def5678
Author: ...
refactor: simplify grade thresholds
最后一行 def5678 is the first bad commit 就是凶手。看一下它改了什么:
git bisect reset # 收尾,回到原来的分支
git show def5678 # 复盘这次回归到底动了哪几行
常见问题:bisect 跑到一半,工作区被法官弄脏了? 因为给了
Read-only 权限,理论上不会改文件;若你放宽了权限,git bisect reset --hard可强制清回干净状态后重启。跑到一半想换 bug 描述? 直接改bug-report.md,已判过的提交结论不会重算——若想全部重来,git bisect reset再重新 start。
把这套流程搬进 CI 时,重点是用 --bare 保证可重现,并捕获会话 ID 以便失败续跑。下面是一个可直接放进 .github/workflows/bisect.yml(或 GitLab CI、Jenkins)的骨架:
name: auto-bisect
on:
workflow_dispatch:
inputs:
good: { required: true } # 例如 v1.4.0
jobs:
bisect:
runs-on: ubuntu-latest
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} # --bare 不读 keychain,必须用环境变量
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # bisect 需要完整历史
- run: chmod +x scripts/judge.sh
- run: |
git bisect start
git bisect bad ${{ github.sha }}
git bisect good ${{ inputs.good }}
git bisect run ./scripts/judge.sh
git bisect reset
几个 CI 专属要点:
--bare 会跳过 OAuth 与 keychain 读取,认证必须来自 ANTHROPIC_API_KEY(或 --settings 里的 apiKeyHelper)。这是脚本模式最容易踩的坑。--output-format json 的响应里带 total_cost_usd 和按模型的费用拆分。在 judge.sh 里顺手 jq -r '.total_cost_usd' 累加,跑完就能在日志里看到这次二分一共花了多少钱。jq -r '.session_id' 存下来,后续用 --resume <session_id> 让法官带着"我已经看过哪些代码"的记忆继续,省去重复读上下文的开销。claude -p 里若起了后台 shell,约 5 秒宽限后会被终止;本场景法官只读不跑服务,不受影响,但如果你改成"法官自己跑测试再判",记得测试要在前台完成。bisect 跑完往往在 CI 或长脚本里,没人盯着。可以用 Claude Code 的 Stop 钩子做收尾:当一次 -p 调用结束,自动把"法官结论 + 凶手提交"推到 IM 或写进归档。
在项目 .claude/settings.json 里加(这是可提交、团队共享的位置):
{
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/scripts/on-judge-done.sh"
}
]
}
]
}
}
on-judge-done.sh 从 stdin 读 JSON(钩子输入字段在 stdin),提取结论:
#!/usr/bin/env bash
# on-judge-done.sh — 每次 -p 法官判定结束后触发
IN=$(cat)
REASON=$(echo "$IN" | jq -r '. // empty' 2>/dev/null)
# 这里可换成飞书/钉钉/Slack webhook,或追加写入 bisect 归档日志
echo "[$(date -u +%FT%TZ)] judge turn finished" >> "${CLAUDE_PROJECT_DIR}/bisect.log"
钩子配置分三层:
hook event(如Stop)→matcher group→hook handler。matcher为空表示对该事件的每次发生都触发。注意--bare模式不会读取这些钩子配置——所以"收尾通知"钩子要在交互式或非 bare 的编排脚本里才生效;纯 CI 的--bare调用想发通知,直接在judge.sh末尾curl一个 webhook 更直接。
下面是一个从零开始、完整可复现的练手项目。我们造一个 grade 模块,故意在中间某次提交引入阈值 off-by-one 回归,然后用 judge.sh 自动揪出它。
1) 建仓库 + 三个提交(前两个 good,第三个 bad):
mkdir bisect-lab && cd bisect-lab && git init -q
mkdir -p src
# commit 1 (good):初版,阈值用 >=
cat > src/grade.py <<'PY'
def letter_grade(score):
if score >= 90: return "A"
if score >= 80: return "B"
if score >= 70: return "C"
if score >= 60: return "D"
return "F"
PY
git add -A && git commit -qm "feat: add letter_grade"
# commit 2 (good):加文档,逻辑不变
cat >> src/grade.py <<'PY'
# Convert a 0-100 numeric score into a letter grade.
PY
git add -A && git commit -qm "docs: document letter_grade"
GOOD=$(git rev-parse HEAD)
# commit 3 (bad):重构时把第一个阈值改成了 > (引入回归!)
python3 - <<'PY'
import re, pathlib
p = pathlib.Path("src/grade.py")
t = p.read_text().replace("score >= 90", "score > 90")
p.write_text(t)
PY
git add -A && git commit -qm "refactor: simplify grade thresholds"
BAD=$(git rev-parse HEAD)
echo "GOOD=$GOOD BAD=$BAD"
2) 写案情描述(沿用步骤 2 的 bug-report.md):
cat > bug-report.md <<'MD'
# 判定标准
- 阅读 src/grade.py 的 letter_grade。
- 所有阈值比较含 `>=`(等号)→ good。
- 任一阈值写成 `>`(无等号)→ bad。
- 只看阈值运算符,忽略其它改动。
MD
3) 把步骤 3 的 judge.sh 放进项目,赋予执行权限。
4) 一键自动二分:
git bisect start
git bisect bad "$BAD"
git bisect good "$GOOD"
git bisect run ./judge.sh
预期结果: 法官在前两个提交上看到 >=,判 GOOD(exit 0);在第三个提交上看到 > 90,判 BAD(exit 1)。git 大约 1–2 步即可锁定,最终输出:
<第三个提交的短 hash> is the first bad commit
5) 复盘与收尾:
git bisect reset
git show HEAD # 确认就是那个 "refactor: simplify grade thresholds"
这个案例虽然小,但完整覆盖了"语义法官"的全部要素:没有写任何针对 letter_grade 的断言测试,纯靠模型读代码 + 标准描述就完成了定位。把它换成你项目里"CSV 表头顺序""错误信息措辞""生成 SQL 的 JOIN 缺失"这类难量化回归,套路一模一样。
flowchart TD
A[确认 BAD=HEAD<br/>GOOD=旧 tag] --> B[编写 bug-report.md<br/>判定标准]
B --> C[编写 judge.sh<br/>调用 claude --bare -p]
C --> D[git bisect start<br/>good / bad / run judge.sh]
D --> E{git checkout 中点提交}
E --> F[judge.sh 读取当前代码]
F --> G[claude -p 当法官<br/>--json-schema 强制 verdict]
G --> H{verdict?}
H -- good --> I[exit 0<br/>git 砍向更新的那一半]
H -- bad --> J[exit 1<br/>git 砍向更旧的那一半]
H -- 调用失败/模糊 --> K[exit 125<br/>跳过此提交]
I --> L{区间收敛?}
J --> L
K --> L
L -- 否 --> E
L -- 是 --> M[输出 first bad commit<br/>git bisect reset]
M --> N[Stop 钩子 / webhook<br/>归档通知]
git bisect skip 跳过 merge,或在 bug-report.md 里明确"以主分支代码为准"。也可以先用 git bisect start -- first-parent 沿第一父链二分,避开合并噪音。judge.sh 里用 --add-dir src/问题模块 限定范围,或直接在 prompt 里点名"只读 src/grade.py",别让模型满仓库乱翻。--bare 又指望钩子生效。 --bare 不读任何本地配置,包括 .claude/settings.json 里的钩子。CI 里想要通知,要么用非 bare 调用,要么在 shell 脚本里直接 curl webhook。exit 125 跳过,绝不能 exit 1——否则一次网络抖动就会把一个无辜的好提交永久标记成"首个坏提交",bisect 结论直接错位。-p / --bare / --output-format / --json-schema / --allowedTools / --resume 的权威说明,本教程所有 flag 语义均出自此处。Stop / PostToolUse / Notification 等事件的完整 schema、matcher 规则、JSON 输入输出与退出码语义。git bisect run 的退出码契约(0/1-124/125/128)与 --first-parent 等高级用法。skills/git-bisect/SKILL.md,提供了不同的脚本组织方式,可借鉴其"AI 先生成测试再 bisect run"的变体思路(适合可自动化的 bug,与本文的"语义法官"互补)。judge.sh 正好是后者的判定内核。-p / --bare / --output-format / --json-schema / --allowedTools / --resume 等 headless 模式 flag 的官方权威说明Stop / PostToolUse / Notification 等钩子事件、matcher 规则、JSON 输入输出与退出码语义git bisect run 的退出码契约(0=good / 1-124=bad / 125=skip / 128=abort)与二分编排机制[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。
