给 feature 分支合入前加一道自动检查:CI 里怎么扫描 commit 历史确保每个提交都可独立回滚

你的 feature 分支能不能安全合入,不该靠人眼 review commit 列表来判断。直接在 CI 里加一个历史扫描步骤,检查每个 commit 是否满足“可独立回滚”的硬性条件,不满足就红掉流水线,这才是可落地的做法。

下面我会给出一套完整的实现方案,包括判断标准、脚本逻辑、CI 配置示例,以及几个你大概率会踩到的坑。

先定义清楚什么叫“可独立回滚”

一个 commit 要能独立回滚,最基本的要求是:git revert <commit> 之后,代码库依然能通过编译和测试。这听起来简单,但实际约束比很多人以为的更严格。

从可操作的角度,我建议把“可独立回滚”拆成两条硬性规则,用脚本就能检查:

  1. 单 commit 不得同时包含“引入某物”和“删除同一物”的操作。典型反例:commit A 新增了 config.go 里的 RetryCount 字段,commit B 又删掉它。revert A 会把字段加回来,但如果 B 之后还有其他逻辑依赖这个字段不存在,revert 结果就是冲突或编译失败。

  2. 单 commit 不得同时修改“基础设施”和“依赖该基础设施的上层代码”。比如你在同一个 commit 里把 UserService 的接口签名从 GetUser(id int) 改成 GetUser(ctx context.Context, id int),同时修改了所有调用方。这个 commit 表面上看是自洽的,但 revert 它之后,所有调用方会回到旧签名,而接口定义也回旧签名——恰好能编译。但问题在于,如果后续 commit 又往这个接口里加了新方法,revert 就会把新方法一起干掉。

这两条规则本质上是在防止“revert 时产生非平凡冲突”。git revert 对纯文本冲突会直接失败,这倒还好,至少 CI 能拦住。怕的是 revert 成功但代码语义已经坏了,测试还恰好没覆盖到。

脚本核心逻辑:用 git loggit 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.jsongo.sum,一个 commit 里新增了依赖,另一个 commit 里又删掉,这本身是合理的,但按规则 1 会被标为“新增后又删除”。你需要在脚本里加一个忽略列表,把这些生成文件排除在检查之外。

第三个坑:revert 本身产生的 commit 会被反向误报。 如果 feature 分支上有 Revert "xxx" 这样的 commit,它本质上是把之前某个 commit 的改动反向应用。按规则 1,它可能同时“删除”了之前“新增”的文件。这种 commit 本身就是可回滚的(revert 一个 revert 是安全的),但脚本会误报。你可以在脚本开头检测 commit message 是否以 Revert 开头,是的话直接跳过。

第四个坑:规则 2 的启发式匹配太宽。 比如一个 commit 修改了 user_service.goorder_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 --mergesgit 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> 然后跑测试,而不是依赖静态扫描。