工程指南

在云端 Mac 上用 git bisect 自动定位 Xcode 回归

在云端 Mac 上用 git bisect 自动定位 Xcode 回归

某个 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 表示正常,1127 表示异常,而 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 配置与五个节点,按实际工作周期选择日、周、月或季租用。

选择云端 Mac 方案