某个 UI 测试从上周开始稳定失败,但最近合入了几十个提交,逐个检出、打开 Xcode、运行测试既慢又容易漏掉环境差异。更有效的做法是把“这个提交是否引入目标故障”写成可重复执行的命令,再交给 git bisect 二分搜索。对于 64 个候选提交,理论上只需约 6 轮判定,真正的难点不是二分算法,而是让每轮结果可信。
先把回归定义成机器能判断的结果
开始前至少准备两个边界:一个确认正常的提交和一个确认异常的提交。不要只写“页面有问题”,而要把回归收窄成单一观察项,例如某个 XCTest 断言失败、指定 Scheme 无法归档,或固定输入产生了错误输出。
判定条件应同时固定以下变量:
- Scheme、Configuration 与测试目标
- Xcode 选择路径和依赖锁文件
- 模拟器型号、系统版本、语言及时区
- 测试数据、网络依赖和执行账户
- 判断正常与异常的唯一证据
git bisect会忠实放大判定器的误差。一次偶发失败若被当成坏提交,后续搜索即使顺利结束,结论也可能完全错误。
先手动在好、坏两个提交上各运行两次相同命令。若结果不能稳定复现,应该先处理测试隔离,而不是立即开始二分。
为每个候选提交隔离执行现场
在 G-Mini 的云端 Mac 上执行时,建议使用专用工作副本,不要在开发者正在编辑的目录里运行。二分过程会频繁切换提交,未跟踪文件、自动生成配置和共享缓存都可能污染判断。
| 隔离项 | 建议做法 | 原因 |
|---|---|---|
| Git 工作区 | 使用独立 clone 或 worktree | 避免覆盖日常修改 |
| DerivedData | 按提交哈希分目录 | 防止旧产物跨提交复用 |
| 模拟器 | 固定 UDID | 避免 destination 自动选择漂移 |
| 结果包 | 每轮单独保存 | 便于复核首个异常提交 |
| 依赖 | 保留锁文件并禁用隐式更新 | 避免版本解析结果变化 |
开始前执行 git status --porcelain,结果必须为空。模拟器应预先创建并启动,再把 UDID 写入环境变量。不要在判定脚本里按名称临时选择“任意可用设备”,同名设备和系统升级都可能改变实际目标。
编写三态 Xcode 判定脚本
git bisect run 不只认识成功和失败。退出码 0 表示正常,1 到 127 表示异常,而 125 表示当前提交无法判断、应跳过。这个第三状态对于历史工程尤其重要:旧提交可能无法使用当前依赖编译,但它不一定包含正在调查的回归。
下面的脚本先验证工程能否构建,再只运行目标测试。基础构建失败被标记为不可判断;测试明确失败才被视为异常。
#!/bin/zsh
set -u
: "${DESTINATION_ID:?Set DESTINATION_ID first}"
sha="$(git rev-parse --short HEAD)"
root="${TMPDIR:-/tmp}/xcode-bisect"
derived="$root/derived-$sha"
result="$root/result-$sha.xcresult"
log="$root/test-$sha.log"
mkdir -p "$root"
rm -rf "$result"
xcodebuild \
-project App.xcodeproj \
-scheme App \
-configuration Debug \
-destination "platform=iOS Simulator,id=$DESTINATION_ID" \
-derivedDataPath "$derived" \
build >"$root/build-$sha.log" 2>&1
if [[ $? -ne 0 ]]; then
exit 125
fi
xcodebuild \
-project App.xcodeproj \
-scheme App \
-destination "platform=iOS Simulator,id=$DESTINATION_ID" \
-derivedDataPath "$derived" \
-resultBundlePath "$result" \
-only-testing:AppTests/CheckoutReducerTests/testExpiredCart \
test >"$log" 2>&1
status=$?
if [[ $status -eq 0 ]]; then
exit 0
fi
if grep -q "TEST FAILED" "$log"; then
exit 1
fi
exit 125
根据故障类型调整分类
如果调查的是编译回归,构建失败就应返回 1,而不是 125。如果调查的是行为回归,则依赖下载失败、模拟器服务异常、磁盘写入失败等基础环境问题必须跳过。不要把所有 xcodebuild 的非零退出码统一解释成目标回归。
启动二分并复核首个异常提交
准备好脚本后,记录当前分支和工作区状态,再执行:
chmod +x ./scripts/bisect-xcode.sh
export DESTINATION_ID="固定的模拟器UDID"
git bisect start
git bisect bad BAD_COMMIT
git bisect good GOOD_COMMIT
git bisect run ./scripts/bisect-xcode.sh
搜索结束后,Git 会指出首个异常提交。此时不要立即关闭问题,应在该提交及其父提交上分别手动运行脚本,并检查保存的构建日志与 .xcresult。还要阅读提交差异,确认它确实能解释观察到的行为,而不是恰好触发了另一个失败。
完成后执行 git bisect reset 返回原分支。若二分期间产生大量 DerivedData,可在复核结束后统一清理;调查阶段不要过早删除结果包,否则会失去判断过程的证据。
处理偶发测试与历史断层
偶发测试采用多数判定
单次执行不稳定时,可让判定器连续运行三次,至少两次出现同一目标失败才返回 1。三次结果互相矛盾时返回 125。这会增加运行时间,但比在错误提交上继续调查更便宜。重复执行前仍要重置测试数据,不能让上一轮状态影响下一轮。
跳过过多时缩小边界
大量 125 会使 Git 无法给出唯一提交。常见原因是工程格式、依赖管理方式或测试名称在历史中发生过变化。此时应把搜索范围切到迁移之后,或为旧目录结构增加兼容分支,但不要让脚本自动修改被测源码。
最终应保留好提交、坏提交、首个异常提交、判定脚本版本、模拟器 UDID、Xcode 版本和结果包路径。这样定位结果才能被另一位工程师复跑,也能在修复提交完成后原样作为回归验证。
常见问题
git bisect 脚本应该返回哪些退出码?
正常提交返回 0,确认存在目标回归时返回 1,无法可靠判断的提交返回 125。不要把依赖下载失败、模拟器未启动或工程暂时无法编译直接判为坏提交。
偶发测试可以直接交给 git bisect 定位吗?
不建议直接使用单次结果。先固定模拟器、语言、时区和测试数据,再让每个提交重复运行至少三次;只有失败达到预设阈值时才返回 1,否则返回 125。
为下一条构建队列准备云端 Mac
比较两档 M4 配置与五个节点,按实际工作周期选择日、周、月或季租用。