一个中型 Swift 仓库在云端 Mac 上执行全量格式检查时,常见问题不是工具本身慢,而是每次合并请求都重复扫描未修改的源码、生成目录和外部依赖。更麻烦的是,开发者本地与 CI 使用不同版本,导致同一行代码在两边得到不同结论。解决办法不是关闭规则,而是把工具版本、配置文件和差异计算一起纳入仓库。
先划清两种工具的职责
SwiftFormat 适合处理可确定、可自动修复的排版,例如缩进、换行和多余空格;SwiftLint 更适合检查强制转换、过长函数、命名约束等风险模式。若两者同时管理同一条规则,开发者可能刚执行完格式化,随后又被静态检查拒绝。
建议先建立一张简单的规则归属表:
| 检查类型 | 负责工具 | 合并请求行为 |
|---|---|---|
| 缩进、空白、参数换行 | SwiftFormat | 只检查,不自动改写 |
| 强制转换与强制解包 | SwiftLint | 失败并输出文件位置 |
| 生成代码与外部依赖 | 两者均排除 | 不进入差异文件列表 |
| 历史遗留告警 | SwiftLint 基线 | 仅阻止新增问题 |
CI 中不要直接执行会改写源码的命令。门禁只负责报告差异;开发者在本地修复后再提交,仓库状态才容易复现。
把版本与规则放进仓库
不要依赖云端 Mac 上“当前安装”的工具。先选取团队已经验证过的版本,再把预期版本写进脚本。下面以 SwiftFormat 0.54.3 和 SwiftLint 0.55.1 为示例;实际项目应替换成自己的验证结果。
.swiftformat 可以保持短小:
--swiftversion 5.10
--indent 4
--wraparguments before-first
--exclude .build,DerivedData,Vendor,Generated
.swiftlint.yml 则明确源码范围和忽略目录:
included:
- Sources
- Tests
excluded:
- .build
- DerivedData
- Vendor
- Generated
only_rules:
- force_cast
- force_try
- trailing_whitespace
- unused_import
规则文件必须随代码评审。新增规则时,先在独立分支跑全仓扫描,确认告警规模,再决定一次性修复还是建立基线。不要把数百条旧告警直接带入合并请求门禁,否则团队最终只会习惯忽略红灯。
正确提取合并请求的 Swift 差异
直接使用 git diff HEAD^ 只适合单提交分支。合并请求包含多次提交或经过变基后,这种写法容易漏文件。更稳妥的方式是先计算当前提交与目标分支的共同祖先,再提取新增、复制、修改和重命名的文件。
set -euo pipefail
expected_format="0.54.3"
expected_lint="0.55.1"
[[ "$(swiftformat --version)" == "$expected_format" ]]
[[ "$(swiftlint version)" == "$expected_lint" ]]
base_ref="${LINT_BASE_REF:-origin/main}"
merge_base="$(git merge-base HEAD "$base_ref")"
changed=("${(@0)$(git diff --name-only -z --diff-filter=ACMR "$merge_base" HEAD)}")
swift_files=()
for file in "${changed[@]}"; do
[[ "$file" == *.swift ]] || continue
[[ "$file" == .build/* ]] && continue
[[ "$file" == DerivedData/* ]] && continue
[[ "$file" == Vendor/* ]] && continue
[[ "$file" == Generated/* ]] && continue
swift_files+=("$file")
done
(( ${#swift_files[@]} > 0 )) || exit 0
swiftformat --lint "${swift_files[@]}"
swiftlint lint --strict --force-exclude "${swift_files[@]}"
这里使用以空字符分隔的文件列表,带空格的路径也不会被拆坏。--diff-filter=ACMR 排除了已删除文件,避免检查工具收到不存在的路径。目标分支名称通过环境变量传入,同一脚本既能在默认分支上运行,也能服务发布分支。
处理浅克隆
若 git merge-base 找不到共同祖先,通常不是脚本错误,而是 CI 只拉取了很浅的提交历史。应先增加检出深度,确保目标分支引用存在,再执行脚本。不要退回 HEAD^,否则门禁会在不同分支结构下产生不一致结果。
让失败结果可定位也可复现
门禁失败后,日志至少应回答三个问题:用了什么工具版本、比较哪个基准提交、哪些文件进入检查。版本不匹配应立即停止,而不是继续运行并制造大量格式差异。
本地复现时,开发者只需拉取目标分支引用并执行同一脚本:
git fetch origin main
LINT_BASE_REF=origin/main zsh Scripts/lint-changed-swift.zsh
常见误区是把检查命令串在管道后面,再读取错误的退出码;或者为了生成漂亮日志使用 || true,结果真实失败被吞掉。脚本启用 set -euo pipefail 后,任何工具返回非零状态都会终止任务。若 CI 需要整理日志,应先保存原始退出码,输出摘要后再按原值退出。
用双层策略控制长期成本
增量门禁只保证“本次变更没有引入新问题”,不能证明旧代码全部符合当前规则。因此可以把检查拆成两层:
- 每个合并请求运行差异检查,目标是快速、稳定,并能在本地完全复现。
- 定时任务运行全仓检查,用于发现基线漂移、失效排除项和长期未处理的旧告警。
- 规则升级单独提交,不与业务改动混在一起,便于审阅格式化造成的大面积变化。
- 生成目录必须有明确来源,既从工具配置排除,也从差异脚本过滤,避免生成任务顺序影响结果。
上线前再核对一次:目标分支引用是否存在,工具版本是否精确匹配,重命名文件是否被纳入,删除文件是否被排除,带空格路径是否安全,以及无 Swift 变更时是否正常退出。完成这些检查后,代码风格门禁才会从“偶尔报错的脚本”变成可靠的提交边界。
常见问题
为什么增量检查仍然需要保留全仓检查?
增量门禁适合每个合并请求快速反馈,但无法发现未修改旧文件中的规则漂移。建议合并请求检查变更文件,同时定时运行一次全仓检查。
SwiftFormat 和 SwiftLint 应该使用同一套规则吗?
两者职责应分开:SwiftFormat 负责可自动修复的排版,SwiftLint 负责风险模式与团队约束。重复规则要明确由其中一个工具负责,避免冲突。
用云端 Mac mini 运行下一项开发或构建任务
从两档 M4 配置、四种租用周期和五个在售节点中选择,实际可用状态以控制台实时返回为准。