s-blog

把 GitHub 大型仓库完整迁移到内网 GitLab——当服务端有 commit message 校验钩子时

CE 没有 Push Rules、服务器不通外网、提交必须以 feat 开头:一次绕开服务端钩子保留 15 万条历史的实战复盘

ssssmy · 2026-06-10 · 12 min

把 GitHub 大型仓库完整迁移到内网 GitLab——当服务端有 commit message 校验钩子时

本文记录一次把 GitHub 上的大型开源仓库(以 VS Code 为例,15 万+ 提交)完整迁移到内网 GitLab、并保留全部提交历史的实战过程。难点在于:目标 GitLab 配置了"提交信息必须以 feat 开头"的服务端校验,而被迁移仓库的历史显然不满足这个规则。文中涉及的主机名、项目名、令牌等均已脱敏。

一、背景与约束

需求很简单:把 https://github.com/<org>/<repo> 的某个发布版本(tag X.Y.Z)迁到内网 GitLab https://git.example.internal/<group>/<project>.git,保留完整提交历史(分支、tag、每一条 commit)。

但现场有三个硬约束,逐一改变了方案:

  1. 内网 GitLab 服务器无法访问外网 GitHub。 所以不能用 GitLab 自带的"Import from URL / Repository Mirroring"——那需要 GitLab 服务器自己去拉 GitHub。
  2. GitLab 上有 commit message 校验:提交信息必须以 feat 开头。 而开源仓库的历史提交五花八门,绝大多数不符合,直接推会被拒。
  3. 没有 GitLab 服务器的 SSH / 管理员权限。 手上只有一个具备 api 权限的访问令牌(项目 Maintainer 级别)。

一句话:得在一台既能访问 GitHub、又能访问内网 GitLab 的机器上做中转,同时想办法让不合规的历史提交"绕过"校验进库,且不能动服务器配置。

二、第一个弯路:以为是 Push Rules

GitLab 企业版(EE/Premium)有个功能叫 Push Rules,可以在项目设置里配置 “Commit message 必须匹配某正则”。它还支持项目级规则覆盖全局规则,优先级是 项目级 > 群组级 > 实例级

所以最初的设想很自然:

给目标项目临时加一条项目级 Push Rule,把 commit message 正则设为空 → 覆盖全局的 feat 规则 → 推完历史再删掉这条项目级规则恢复。这样只影响这一个项目、只放开几分钟,风险最小。

对应的 API 大概是:

POST /api/v4/projects/:id/push_rules
{ "commit_message_regex": "" }

结果调用直接返回 404。排查后才反应过来——

# 查 GitLab 版本
curl -H "PRIVATE-TOKEN: <token>" https://git.example.internal/api/v4/version
# => {"version":"13.3.2", ...}  ←  Community Edition(社区版)

社区版(CE)根本没有 Push Rules 这个功能,它是企业版专属。push_rules API 在 CE 上不存在,所以 404。

教训:动手前先确认 GitLab 是 CE 还是 EE、以及具体版本。很多"GitLab 高级功能"(Push Rules、仓库镜像 Mirroring、合规流水线等)都是 EE 专属,在 CE 上压根没有对应入口和 API。

那么 CE 上这个 feat 校验是怎么实现的?答案是:自定义服务端钩子(custom server-side hook),通常是 GitLab 数据目录下该项目的 custom_hooks/pre-receive 脚本。它不在任何 API 的管辖范围内,只有能登录服务器的人才能改。我手上没有服务器权限——"临时关闭校验"这条路彻底走不通。

三、关键探测:钩子是怎么校验的?

既然改不了钩子,那就得搞清楚它到底校验什么、什么时候校验。于是做了一次最小化探测:建一个本地仓库,造一条明显不合规的提交,推到一个全新分支,看会发生什么。

git init probe && cd probe
echo probe > probe.txt
git add . && git commit -m "test probe: not feat prefixed"   # 故意不以 feat 开头

git push https://<token>@git.example.internal/<group>/<project>.git \
  HEAD:refs/heads/hook-probe-temp

输出耐人寻味:

remote: fatal: Invalid revision range 0000000000000000000000000000000000000000..fa7faaa...
remote:
To https://git.example.internal/<group>/<project>.git
 * [new branch]      HEAD -> hook-probe-temp

钩子报了个 fatal: Invalid revision range,但分支居然创建成功了,那条不合规的提交也真的进库了(随后用 API 核实过)。

这暴露了钩子的实现细节。服务端 pre-receive 钩子读取 <old-sha> <new-sha> <ref>,这个校验脚本内部大概是这么写的:

# 伪代码:逐条检查本次推送涉及的提交
while read oldrev newrev refname; do
  for commit in $(git rev-list "$oldrev..$newrev"); do   # ← 问题在这一行
    msg=$(git log -1 --format=%s "$commit")
    [[ "$msg" =~ ^feat ]] || { echo "提交信息必须以 feat 开头"; exit 1; }
  done
done

当推送的是新建分支时,oldrev 是 40 个 0(全零 SHA,表示"此前不存在")。于是 git rev-list 0000..newrev 是一个非法的 revision range,git rev-list 直接报错退出,for 循环拿不到任何提交,校验逻辑被整段跳过——脚本最终以 0 退出,推送放行。这就是所谓的 fail-open(失败放行):校验逻辑出错时,不是拒绝,而是直接通过。

结论:这个钩子只能有效校验"对已存在分支的更新"(oldrev 是真实 commit 的情况);对"新建分支 / 新建 tag / 删除分支"(任意一端是全零 SHA)一律 fail-open 放行。

这其实是该钩子的一个安全缺陷:任何人都能通过"新建一个分支"把不合规的提交推进仓库。一个健壮的写法应当用 git rev-list "$newrev" --not --all(列出新引入、且尚不在任何已有 ref 上的提交)来处理新建分支,并在 git rev-list 出错时 fail-close(拒绝)而非放行。本文利用的正是这个缺陷——后文会讨论这么做的边界。

探测完顺手清理掉临时分支:

git push https://<token>@git.example.internal/<group>/<project>.git --delete hook-probe-temp

四、方案定型

把已知条件拼起来,方案就清晰了:

  • 在中转机上 git clone GitHub 仓库(中转机能访问 GitHub,GitLab 服务器不需要)。
  • 把历史推送到一个全新分支(而不是更新已有的 master)。新建分支 → 钩子 fail-open → 15 万条不合规的历史提交原样进库。
  • 不碰服务器、不碰任何配置;日常开发分支的 feat 校验照常生效(因为那是"更新已有分支")。

唯一要注意的:目标分支必须是"新的"。如果推到已经有提交的 master,那是"更新已有分支",钩子会逐条校验,直接被拒。所以最终落地为一个独立分支,例如 mirror/X.Y.Z,master 保持不动。

五、动手实施

5.1 克隆(裸镜像,只取目标 tag 的历史)

--bare 裸克隆,只要历史不要工作区,省时省空间;--single-branch --branch X.Y.Z 只取目标版本可达的历史。

git clone --bare --single-branch --branch X.Y.Z \
  https://github.com/<org>/<repo>.git ./repo-mirror.git

克隆完核对一下:

cd repo-mirror.git
git rev-list --count X.Y.Z          # => 158370(提交总数)
git log -1 --format='%H %s' X.Y.Z   # => 6a44c3...  目标 tag 指向的提交

小贴士:--bare 仓库打开后看不到源码文件,这是正常的——它只存 Git 对象数据库(objects/refs/HEAD 等),代码压缩在 objects/pack/*.pack 里。想看到摊开的源码,得另外 git clone <裸仓库路径> 出一份带工作区的副本。

5.2 推送到全新分支

把 tag 指向的那个 commit 推到一个新分支引用 refs/heads/mirror/X.Y.Z:

git push https://<token>@git.example.internal/<group>/<project>.git \
  6a44c352...:refs/heads/mirror/X.Y.Z

输出(Invalid revision range 是预期的放行信号,不是失败):

remote: fatal: Invalid revision range 0000...0000..6a44c352...
To https://git.example.internal/<group>/<project>.git
 * [new branch]   6a44c352... -> mirror/X.Y.Z

再推 tag(新建 tag 同样 fail-open 放行):

git push https://<token>@git.example.internal/<group>/<project>.git refs/tags/X.Y.Z
# * [new tag]   X.Y.Z -> X.Y.Z

5.3 校验结果(用 API 核对,而不是凭推送日志)

由于推送日志里夹着 fatal: ...,容易看花眼,所以一律用 API 复核——分支指向、提交总数、tag 指向,都要和本地一致:

TOKEN=<token>; BASE=https://git.example.internal/api/v4/projects/<id>
BR=mirror%2FX.Y.Z   # 分支名里的 / 要 URL 编码成 %2F

# 分支指向的 commit
curl -s -H "PRIVATE-TOKEN: $TOKEN" "$BASE/repository/branches/$BR"

# 提交总数:看响应头 X-Total
curl -sI -H "PRIVATE-TOKEN: $TOKEN" \
  "$BASE/repository/commits?ref_name=$BR&per_page=1" | grep -i x-total
# => X-Total: 158370   ←  与本地 git rev-list --count 完全一致

# 确认 master 未被改动、默认分支未变
curl -s -H "PRIVATE-TOKEN: $TOKEN" "$BASE" | jq .default_branch

远端 X-Total: 158370 与本地 git rev-list --count 完全相等,15 万条历史一条不少,迁移成功

六、固化成可复用脚本

VS Code 这类项目会持续发版,每来一个新版本都手敲一遍既繁琐又容易出错。于是把整套流程固化成一个 PowerShell 脚本(放在中转机上,也可换成 Bash),核心要点:

  1. 令牌作为参数传入,绝不写进文件,避免泄露。
  2. 推送前先校验目标分支不存在——若已存在直接中止,从根上杜绝"误更新已有分支被钩子拦截"。
  3. 利用已有的本地裸镜像做增量抓取(git fetch <github> refs/tags/X.Y.Z:refs/tags/X.Y.Z),只传输新版本相对旧版本的增量对象,不必重新下载整个仓库。
  4. 推送后用 API 自动核对分支 commit、提交总数、tag。

脚本骨架(已脱敏,关键步骤示意):

param(
  [Parameter(Mandatory)][ValidatePattern('^\d+\.\d+\.\d+$')][string]$Version,
  [Parameter(Mandatory)][string]$Token,
  [string]$MirrorPath = '.\repo-mirror.git'
)
$ErrorActionPreference = 'Stop'
$branch = "mirror/$Version"
$enc    = [uri]::EscapeDataString($branch)
$api    = "https://git.example.internal/api/v4/projects/<id>"
$push   = "https://oauth2:[email protected]/<group>/<project>.git"
$hdr    = @{ 'PRIVATE-TOKEN' = $Token }

# 1) 目标分支已存在 → 直接中止(绝不更新已有分支)
try {
  Invoke-RestMethod -Headers $hdr -Uri "$api/repository/branches/$enc" -ErrorAction Stop | Out-Null
  throw "分支 $branch 已存在,中止(更新已有分支会触发 feat 校验)。"
} catch { if ($_.Exception.Response.StatusCode.value__ -ne 404) { throw } }

# 2) 从 GitHub 增量抓取该 tag
Set-Location $MirrorPath
git fetch https://github.com/<org>/<repo>.git "refs/tags/${Version}:refs/tags/${Version}"
$sha = (git rev-parse "${Version}^{commit}").Trim()

# 3) 推到全新分支 + tag(新建 → fail-open 放行)
git push $push "${sha}:refs/heads/$branch"
git push $push "refs/tags/$Version"

# 4) API 校验提交总数
$r = Invoke-WebRequest -Headers $hdr -UseBasicParsing `
       -Uri "$api/repository/commits?ref_name=$enc&per_page=1"
"远端提交数: $($r.Headers['X-Total']) / 本地: $(git rev-list --count $Version)"

一个 Windows 上的坑:中文 Windows 的 PowerShell 5.1 默认按 ANSI(GBK)码页读取 .ps1 文件。如果脚本含中文注释又存成"UTF-8 无 BOM",运行时会把多字节误解码,报一堆莫名其妙的括号/语法错误。解决办法:把脚本存成 UTF-8 with BOM,PS 会识别 BOM 并正确按 UTF-8 解析。

七、后续维护规约(很重要)

迁移之后,团队要守住一条铁律,否则迟早踩坑:

  • 每个新版本 = 一个新分支 mirror/X.Y.Z + 一个新 tag。 这是唯一能 fail-open 放行的路径。
  • 绝不向已存在的镜像分支推新提交、也不要 git push --mirror / --all 这些都是"更新已有分支",钩子会逐条校验,非 feat 提交一律被拒。
  • 因为镜像分支已在 GitLab 上,推下一个版本时只会传输增量对象,很快,不会再传一遍整个仓库。
  • 校验只对"更新"生效,所以历史能进、而团队日常开发分支的 feat 规范丝毫不受影响——两者并不矛盾。

八、关于"绕过校验"的边界讨论

本文利用了钩子 fail-open 的缺陷把历史提交推进库,有必要说清楚边界:

  • 它绕过的是格式规范(commit message 前缀),不是权限或安全控制。 推送动作本身仍然经过了正常的令牌鉴权,操作者本就有该项目的写权限。这与"越权""提权"是两回事。
  • 它是一次性的历史导入,导入的是公开开源代码的既有历史,改写这些历史的 message 反而会破坏可追溯性(commit hash 全变、与上游对不上)。
  • 对新提交的治理没有被削弱:fail-open 只在"建分支/建 tag"时发生,日常的"更新分支"照常强制 feat
  • 如果你是 GitLab 管理员,看到这篇文章应该做的是修钩子:用 git rev-list "$newrev" --not --all 正确处理新建分支的情形,并在 git rev-list 出错时 fail-close。本文的方法在修复后即失效——这恰恰说明它依赖的是缺陷而非设计。

九、小结

  • 动手前先确认 GitLab 的版本与 CE/EE 类型;CE 没有 Push Rules / Mirroring,很多设想会落空。
  • CE 上的"提交规范校验"通常是服务端自定义钩子,API 管不到,无服务器权限就改不了。
  • 用最小化探测搞清楚钩子校验什么、何时校验,往往能找到合规且无副作用的路径——本例中"推到全新分支"既保留了完整历史,又不影响日常治理。
  • 大仓库迁移用 --bare 裸镜像 + 增量 fetch,省时省空间;结果一律用 API 核对提交总数,不要只看推送日志。
  • 把流程固化成脚本时,注意令牌不落盘、推送前防呆校验、以及 Windows PowerShell 的脚本编码(UTF-8 with BOM)。

本文为脱敏后的技术复盘,主机名、项目路径、令牌等均为占位符。

原文链接:https://www.ssssmy.com/blog/ba-github-da-xing-cang-ku-wan-zheng-qian-yi-dao-nei-wang-gitlab-dang-fu-wu-duan-you-commit-message-jiao-yan-gou-zi-shi