给 feature 分支合入前加一道自动检查:CI 里怎么扫描 commit 历史确保每个提交都可独立回滚
你的 feature 分支能不能安全合入,不该靠人眼 review commit 列表来判断。直接在 CI 里加一个历史扫描步骤,检查每个 commit 是否满足“可独立回滚”的硬性条件,不满足就红掉流水线,这才是可落地的做法。
下面我会给出一套完整的实现方案,包括判断标准、脚本逻辑、CI 配置示例,以及几个你大概率会踩到的坑。
先定义清楚什么叫“可独立回滚”
一个 commit 要能独立回滚,最基本的要求是:git revert <commit> 之后,代码库依然能通过编译和测试。这听起来简单,但实际约束比很多人以为的更严格。
从可操作的角度,我建议把“可独立回滚”拆成两条硬性规则,用脚本就能检查:
-
单 commit 不得同时包含“引入某物”和“删除同一物”的操作。典型反例:commit A 新增了
config.go里的RetryCount字段,commit B 又删掉它。revert A 会把字段加回来,但如果 B 之后还有其他逻辑依赖这个字段不存在,revert 结果就是冲突或编译失败。 -
单 commit 不得同时修改“基础设施”和“依赖该基础设施的上层代码”。比如你在同一个 commit 里把
UserService的接口签名从GetUser(id int)改成GetUser(ctx context.Context, id int),同时修改了所有调用方。这个 commit 表面上看是自洽的,但 revert 它之后,所有调用方会回到旧签名,而接口定义也回旧签名——恰好能编译。但问题在于,如果后续 commit 又往这个接口里加了新方法,revert 就会把新方法一起干掉。
这两条规则本质上是在防止“revert 时产生非平凡冲突”。git revert 对纯文本冲突会直接失败,这倒还好,至少 CI 能拦住。怕的是 revert 成功但代码语义已经坏了,测试还恰好没覆盖到。
脚本核心逻辑:用 git log 和 git diff 逐 commit 扫描
先给结论:单靠 git log --oneline 看 commit message 是没用的,必须对每个 commit 的实际 diff 做结构化检查。
下面是一个可用的 Python 脚本,核心思路是对 feature 分支上每个 commit,分别 diff 出“新增文件”“删除文件”“修改文件”三个集合,然后做规则匹配。
# !/usr/bin/env python3
"""
check_revertable_commits.py
扫描 feature 分支相对 main 的每个 commit,检查是否满足可独立回滚规则。
用法:
python3 check_revertable_commits.py main..HEAD
"""
import subprocess
import sys
import re
def run(cmd):
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
if result.returncode != 0:
print(f"命令执行失败: {cmd}\n{result.stderr}", file=sys.stderr)
sys.exit(1)
return result.stdout.strip()
def get_changed_files(commit):
"""返回 (added, deleted, modified) 三个集合,路径为仓库根相对路径。"""
added = set()
deleted = set()
modified = set()
# --diff-filter 分别筛选
raw = run(f"git diff-tree --no-commit-id --name-status -r {commit}")
if not raw:
return added, deleted, modified
for line in raw.splitlines():
parts = line.split('\t')
if len(parts) < 2:
continue
status = parts[0]
path = parts[1]
if status == 'A':
added.add(path)
elif status == 'D':
deleted.add(path)
elif status in ('M', 'T', 'R100'):
modified.add(path)
# R 带相似度的情况如 R90,也归为 modified
elif status.startswith('R'):
modified.add(path)
return added, deleted, modified
def main():
if len(sys.argv) != 2:
print("用法: python3 check_revertable_commits.py <commit-range>", file=sys.stderr)
sys.exit(1)
commit_range = sys.argv[1]
commits = run(f"git rev-list --reverse {commit_range}").splitlines()
if not commits:
print("没有需要检查的 commit。")
return
violations = []
for commit in commits:
subject = run(f"git log -1 --format=%s {commit}")
added, deleted, modified = get_changed_files(commit)
# 规则 1:同一路径在同一个 commit 里既新增又删除
add_del_overlap = added & deleted
if add_del_overlap:
violations.append(
f"[{commit[:8]}] {subject}\n"
f" 同一 commit 内新增后又删除的文件: {', '.join(sorted(add_del_overlap))}"
)
# 规则 2a:同一 commit 内同时修改了同一目录下的接口定义和调用方
# 这里用一个简化启发式:如果某个文件被修改,且同目录下存在
# 文件被删除,则标记为可疑。实际项目中建议替换为更精确的
# 接口签名变更检测(比如用 go/ast 或 tree-sitter)。
for path in modified:
dirname = '/'.join(path.split('/')[:-1])
if any(d.startswith(dirname + '/') for d in deleted):
violations.append(
f"[{commit[:8]}] {subject}\n"
f" 可疑的修改-删除组合: {path} (修改) 与同目录下删除文件"
)
break
# 规则 2b:同一个 commit 里同时修改了同一个文件的“定义”和“调用”
# 简化检测:如果 commit 修改了 .go 文件,且该文件路径包含
# interface 或 service 关键字,同时另一个 .go 文件也被修改,
# 标记为需要人工确认。
go_files = [f for f in modified if f.endswith('.go')]
if len(go_files) >= 2:
definition_like = [f for f in go_files if
'interface' in f.lower() or
'service' in f.lower() or
'provider' in f.lower()]
if definition_like:
violations.append(
f"[{commit[:8]}] {subject}\n"
f" 同一 commit 修改了疑似定义文件 {', '.join(definition_like)} "
f"及其他 {len(go_files) - 1} 个 .go 文件,需确认是否破坏可回滚性"
)
if violations:
print("发现不可独立回滚的 commit:\n")
for v in violations:
print(v)
print()
sys.exit(1)
else:
print("所有 commit 均通过可独立回滚检查。")
if __name__ == '__main__':
main()
这个脚本里的规则 2 我用的是启发式,因为“接口定义变更”的精确检测需要语言级分析,用正则或文件路径匹配只能做粗筛。如果你的项目是 Go,可以进一步用 go/ast 把每个 commit 的 diff 解析成 AST 变更,对比函数签名。但就 CI 拦截的实用角度,粗筛 + 人工确认已经能挡住大部分问题。
接入 CI:以 GitHub Actions 为例
把脚本放进仓库的 scripts/ 目录,然后在 CI 里加一个 job。关键点是必须用 fetch-depth: 0,否则 Actions 默认只拉取最近一次 commit,git diff-tree 拿不到完整历史。
name: Commit History Check
on:
pull_request:
branches: [main]
jobs:
revertable-check:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Run revertable commit check
run: |
python3 scripts/check_revertable_commits.py origin/main..HEAD
这里 origin/main..HEAD 是 GitHub Actions 环境里可用的范围表示。如果你在 GitLab CI 里做,对应的是 $CI_MERGE_REQUEST_TARGET_BRANCH_NAME..$CI_COMMIT_SHA,并且同样要确保 shallow clone 被禁用。
这个检查放哪个阶段最合适
放 PR 触发阶段,而不是 push 阶段。原因很简单:push 阶段你还没决定要不要合入,feature 分支上中间过程不干净是常态。只有在 PR 合入前,你才需要确保“这个分支作为整体合入后,未来每个 commit 都能独立 revert”。
如果你在 push 阶段就拦住,开发者会被迫在开发过程中频繁 squash、拆分 commit,反而拖慢迭代节奏,而且那些中间状态的 commit 本来也不会进主分支。
另外建议把 check_revertable_commits.py 和 lint、unit test 分开成独立 job,这样失败时开发者一眼就能看到是“commit 历史问题”而不是“代码质量问题”。
实际落地时你要面对的四个坑
第一个坑:merge commit 会让 git rev-list 的输出变得复杂。 如果你的 feature 分支经常从 main 拉取更新,git rev-list main..HEAD 里不会包含 merge commit 本身(因为 merge commit 的 parent 有一个在 main 上),但它的第二个 parent 链上的 commit 会被包含进来。这些 commit 通常已经在 main 上通过了检查,重复扫描会误报。解决办法是在脚本里过滤掉已经在 main 上的 commit,或者要求团队用 rebase 而不是 merge 来同步分支。
第二个坑:大文件或生成文件的误报。 比如 package-lock.json 或 go.sum,一个 commit 里新增了依赖,另一个 commit 里又删掉,这本身是合理的,但按规则 1 会被标为“新增后又删除”。你需要在脚本里加一个忽略列表,把这些生成文件排除在检查之外。
第三个坑:revert 本身产生的 commit 会被反向误报。 如果 feature 分支上有 Revert "xxx" 这样的 commit,它本质上是把之前某个 commit 的改动反向应用。按规则 1,它可能同时“删除”了之前“新增”的文件。这种 commit 本身就是可回滚的(revert 一个 revert 是安全的),但脚本会误报。你可以在脚本开头检测 commit message 是否以 Revert 开头,是的话直接跳过。
第四个坑:规则 2 的启发式匹配太宽。 比如一个 commit 修改了 user_service.go 和 order_service.go,两个文件名都包含 service,脚本就会标记为“疑似定义文件变更”。这在微服务项目里几乎每个 commit 都会触发。你需要根据自己项目的目录结构收紧规则,比如只匹配 internal/service/ 或 pkg/api/ 下的文件,而不是全局匹配文件名关键字。
如果团队接受不了“每个 commit 都可回滚”,退而求其次的方案
有些团队的分支策略是“feature 分支合入时 squash 成一个 commit”,这种情况下单个 commit 必然包含大量改动,revert 它等于 revert 整个 feature,可回滚性由“feature 作为一个整体”来保证。
如果是这种情况,你的 CI 检查目标就不该是“每个 commit 可独立回滚”,而应该是“feature 分支没有从 main 反向合入的 commit”和“没有 revert 循环”。这两条用 git log --merges 和 git log --grep='^Revert' 就能查,比上面的脚本简单得多。
但我要提醒一句:squash 合入虽然让主分支历史干净,代价是失去了在主分支上做精细 revert 的能力。一旦 feature 上线后发现某个子改动有问题,你只能整体回滚整个 feature。如果你的发布节奏快、feature 颗粒度小,这没问题;如果 feature 很大、包含多个独立模块,建议还是保留分 commit 合入,同时用上面的脚本做检查。
常见问题
这个检查会不会拖慢 CI?
不会。git diff-tree 对每个 commit 只跑一次文件列表,不涉及 diff 内容解析,100 个 commit 的分支也就几秒钟。真正的开销在 fetch-depth: 0 拉全量历史上,如果你的仓库很大(比如有几百 MB 的二进制文件历史),建议用 --filter=blob:none 做 blobless clone,只拉 tree 和 commit 信息,足够跑这个检查。
规则 2 的启发式匹配能不能直接用?
建议先跑一遍你的真实分支,看看误报率。如果误报超过 20%,说明规则太宽,需要按你自己的项目结构调整。理想状态是:规则 2 只在真正可疑的时候触发,比如同目录下既有 .go 文件被修改又有 .proto 文件被修改(接口定义变更),或者 go.mod 和多个 _test.go 同时被修改。
这个检查能挡住所有不可回滚的情况吗?
挡不住。它只能挡住“结构上明显不可回滚”的 commit,比如同一 commit 内自相矛盾的改动。真正语义上的不可回滚——比如 revert 之后测试恰好没覆盖到坏掉的路径——需要靠 revert 后的全量 CI 来验证。如果你对某个 commit 的可回滚性有疑虑,最可靠的做法是本地跑一次 git revert --no-commit <commit> 然后跑测试,而不是依赖静态扫描。