用 git merge-base 精准运行 PR 变更文件的 Ruff 与 Pytest
用 git merge-base 精准运行 PR 变更文件的 Ruff 与 Pytest
问题场景
在 Pull Request 的 CI 中,如果每次都执行 ruff check . 和全量 pytest,代码量增长后反馈会变慢;如果只用 HEAD 与目标分支做两点 diff,又可能把目标分支后续提交带来的无关差异混入检查范围。更稳妥的方式是先找到当前分支与目标分支的合并基点,再用三点 diff 只取本 PR 的变更文件。
本文给出一个可放入 CI 的 Shell 脚本:只对变更的 Python 文件运行 Ruff,只为这些文件映射对应测试并运行 pytest;通过 --diff-filter=ACMR 过滤已删除文件;没有 Python 变更时直接跳过。
核心命令
先取合并基点:
git fetch --no-tags origin main
BASE=$(git merge-base origin/main HEAD)
再取本 PR 的 Python 变更文件。--diff-filter=ACMR 表示只保留新增、复制、修改和重命名,删除项不会进入后续命令:
git diff --name-only --diff-filter=ACMR "$BASE"...HEAD -- '*.py'
脚本中使用 -z 与 mapfile -d '',避免文件名含空格时被拆分。重命名文件会按新路径参与 Ruff 与测试映射。
CI Shell 脚本
将以下内容保存为 ci/pr-checks.sh,按项目实际目录调整 TEST_ROOT 与 src/、app/ 前缀处理:
#!/usr/bin/env bash
set -euo pipefail
TARGET_BRANCH="${TARGET_BRANCH:-main}"
TEST_ROOT="${TEST_ROOT:-tests}"
REQUIRE_TESTS="${REQUIRE_TESTS:-0}"
git fetch --no-tags origin "$TARGET_BRANCH"
BASE=$(git merge-base "origin/$TARGET_BRANCH" HEAD)
mapfile -d '' -t changed_py < <(
git diff --name-only -z --diff-filter=ACMR "$BASE"...HEAD -- '*.py'
)
if ((${#changed_py[@]} == 0)); then
echo 'No changed Python files; skip ruff and pytest.'
exit 0
fi
echo 'Changed Python files:'
for f in "${changed_py[@]}"; do echo " $f"; done
ruff check "${changed_py[@]}"
declare -A seen=()
tests=()
add_test() {
local f="$1"
[[ -f "$f" ]] || return 0
[[ -n "${seen[$f]:-}" ]] && return 0
seen["$f"]=1
tests+=("$f")
}
for f in "${changed_py[@]}"; do
rel="${f#./}"
dir=$(dirname "$rel")
stem=$(basename "$rel" .py)
[[ "$dir" == '.' ]] && dir=''
if [[ "$rel" == "$TEST_ROOT"/* && ( "$stem" == test_* || "$stem" == *_test ) ]]; then
add_test "$rel"
continue
fi
case "$dir" in
src/*) dir="${dir#src/}" ;;
app/*) dir="${dir#app/}" ;;
esac
add_test "$TEST_ROOT/${dir:+$dir/}test_${stem}.py"
add_test "$TEST_ROOT/test_${stem}.py"
add_test "$TEST_ROOT/${dir:+$dir/}${stem}_test.py"
done
if ((${#tests[@]} == 0)); then
echo 'No matching test files.'
[[ "$REQUIRE_TESTS" == '1' ]] && exit 1
exit 0
fi
echo 'Selected tests:'
for t in "${tests[@]}"; do echo " $t"; done
pytest -q "${tests[@]}"
测试路径映射
默认按常见约定映射,例如:
src/pkg/a.py会尝试tests/pkg/test_a.py、tests/test_a.py、tests/pkg/a_test.py。tests/pkg/test_b.py本身是变更文件时直接加入测试列表。- 已删除文件由
--diff-filter=ACMR排除,不会触发 Ruff 或 pytest。 - 可通过
REQUIRE_TESTS=1让“源码有变更但没有映射到测试”的情况失败,适合强制补测试的仓库。
如果项目测试目录不是 tests,设置 TEST_ROOT;如果源码前缀不是 src/ 或 app/,按实际结构调整 case 分支。
运行方法
在 CI 或本地执行:
chmod +x ci/pr-checks.sh
TARGET_BRANCH=main bash ci/pr-checks.sh
CI 检出代码时需要保证目标分支可获取。浅克隆可能导致 git merge-base 找不到共同祖先,应使用足够的检出深度,或显式 fetch 目标分支。
验证步骤
- 从目标分支创建演示分支,修改一个源文件并删除一个旧 Python 文件。
- 提交后运行脚本,确认 Ruff 只检查变更文件,删除文件未出现,测试列表包含映射到的测试。
- 再创建一个不包含 Python 变更的提交,运行脚本,确认输出跳过信息并返回 0。
- 将文件重命名后运行,确认新路径参与检查,旧路径不参与。
- 在 CI 中先跑本脚本,夜间流水线再跑全量 pytest,兼顾快速反馈与完整覆盖。
边界与限制
git merge-base依赖完整历史;浅克隆或目标分支缺失时需调整 fetch 策略。- 该方案只覆盖约定路径能映射到的测试,不等同于全量回归;跨模块集成测试仍应定期全量运行。
- Ruff 配置需位于仓库可发现的位置;生成代码或第三方目录如不应检查,应在 Ruff 配置中排除。
- 测试映射是启发式的,复杂项目应维护显式映射表或改用测试影响分析工具。
- 文件名含空格已通过 NUL 分隔处理;含换行符的极端路径仍建议在 CI 中单独验证。
- 点赞
- 收藏
- 关注作者
评论(0)