写这个脚本的起因特别简单:我们团队的项目代码一直分散在 Gitee、GitLab 和 GitHub 三个地方,有的是历史遗留,有的是客户要求,有的是自己开源项目。时间一长问题就来了——每次要跨平台同步仓库,都得先手动在目标平台建仓库,再本地 clone,改 remote,最后 push。单个仓库还能接受,几十个仓库挨个操作下来,人很容易烦躁,而且极容易漏掉某个分支或者 tag。所以我就花了点时间,写了一个 Python 脚本,把"Gitee/GitLab 仓库一键批量迁移到 GitHub"这件事完全自动化。这篇文章就把脚本的核心实现、平台 API 的衔接细节、还有我实际运行中踩过的一堆坑完整记录下来,给同样有批量迁移需求的朋友做一个参考。
1. 从"手动建仓库再推代码"到"一键执行":这个迁移脚本的诞生背景
1.1 触发这个需求的三类典型场景
先说说什么情况下你会需要"批量迁移仓库"。我总结下来最常见的是三类:
第一类是平台备份。很多团队主战场在 Gitee 或者公司内部的 GitLab,但会希望把线上代码在 GitHub 上也留一份副本。一方面是双活备份,另一方面是方便招人、对外展示、做开源前的预热。
第二类是代码托管平台归一化。公司被收购、团队合并、个人账号整理,最后往往会有一个"把所有仓库统一收敛到某个平台"的需求。把 Gitee、GitLab 全都归到 GitHub 是最常见的收敛方向。单仓迁移有手把手的教程,但当你账号下躺着 40 多个仓库时,教程再好你还是得写脚本。
第三类是开源项目对外开源。原本跑在私有 GitLab 或者 Gitee 私有仓库的项目,现在决定开源了,要原原本本搬到 GitHub 上。注意这里"原原本本"很关键——不能只是把当前代码文件传上去,而是要把整个 commit 历史、所有分支、所有 tag 全部搬走,否则对项目后续的 star、issue 追踪、release 管理都有很大影响。
1.2 手动流程的重复成本到底有多高
手动迁移一个仓库的流程,拆开说其实就几步:先去 GitHub 网页上点新建仓库,填名字、选公开还是私有,等它创建完;然后本地git clone --bare源仓库;接着git remote set-url origin改成 GitHub 地址;最后git push --mirror origin推上去。
听起来是不是很简单?但一个仓库走完这套流程,熟练工也要三到五分钟,而且中间有大量等待时间——clone 要等、push 要等,网页创建仓库还要等浏览器反应。如果是三四十个仓库,一个下午基本就交代在这上面了。更难受的是,手动流程极易出低级失误:仓库创建时勾错了 Private 选项,一不小心把内部代码公开了;clone 的时候选错地址,把仓库 A 的内容推到仓库 B 里;漏掉了某个 tag,等你发现的时候已经隔了好几天,根本想不起来是哪个仓库出了问题。
所以我去翻了 GitHub、Gitee、GitLab 三家的 API 文档,确认了最基本的可行性:这三个平台都有成熟的 REST API,支持拉取仓库列表、支持创建仓库;而 Git 本身作为分布式版本控制系统,天然支持从一个 remote 完整镜像到另一个 remote。既然每一环都有 API 或者命令行可以操作,那把它们串成脚本就是顺理成章的事情。
1.3 一个关键认知:迁移必须保留完整 Git 历史
很多人对"迁移仓库"有个误解,以为就像把一堆文件从 A 文件夹复制到 B 文件夹一样,把最新代码拉下来传上去就行了。这种做法最大的问题是它丢掉了一个代码仓库最值钱的东西——提交历史。
代码仓库的历史记录里藏着每次修改的原因、作者、时间、评审记录,这是项目最真实的演进档案。做代码审查、排查线上问题、追查某行代码是谁在什么背景下写出来的,全都依赖这些历史。一旦历史丢了,别人看你这个项目就是一个黑盒子,只有现状没有过程,这对开源项目的可信度是致命打击。
所以我的脚本从一开始就确定了技术路线:用git clone --mirror拉取源仓库镜像,用git push --mirror推送到目标仓库。--mirror参数保证所有分支、所有 tag、所有 refs(包括远端仓库的自定义引用)都被完整复制,而不只是默认分支的那一份代码快照。这也是为什么脚本迁移出来的仓库,在 GitHub 上点开 commit 记录,能和源仓库完全对得上,一行都不差。
2. 迁移的本质不是搬家,是复制 Git 对象库
2.1 为什么说远程仓库迁移的核心是"refs + objects"
要理解这个脚本为什么这样设计,得先把 Git 的底层逻辑讲透。
一个 Git 仓库,本质上由两部分组成:对象库(object database)和引用(refs)。对象库存放的是所有历史版本中产生过的文件内容、目录树、提交记录,是仓库体积的绝对大头;引用则是一些指向特定 commit 的指针,比如分支refs/heads/main、标签refs/tags/v1.0.0。你在 GitHub 网页上看到的"分支列表"和"tag 列表",其实就是远端仓库的 refs 集合。
克隆或者推送时,Git 要做的事情是:保证对象库里把所有 reachable(可达的)对象都传输过去,然后更新远端 refs 指向。--mirror参数的作用就是"把所有 refs 原封不动地镜像过去",包括本地分支、远程跟踪分支、标签,甚至 refs/pull 这种特殊引用网络也会被复制(如果源远端有的话)。普通 clone 只会在本地创建默认分支的跟踪关系,而 mirror clone 会形成一个完全等价的 Git 仓库副本。
2.2 git clone --mirror 与 git clone --bare、git clone 的区别
这三个概念经常被搞混,我在这里一次性说清楚:
- 普通
git clone <url>:把远端仓库克隆到本地,会在目标目录下创建一份工作区文件,默认只跟踪远端的分支,其他分支你需要在本地手动建跟踪。这是日常开发最常用的。 git clone --bare <url>:克隆出来的仓库只有.git目录的内容,没有工作区文件,适合作为"裸仓库"或者服务端仓库使用。但它不一定包含远端的全部 refs。git clone --mirror <url>:等价于--bare,但它额外保证了远端所有的 refs 都被镜像到本地。这是"异地容灾""仓库搬迁"场景下的标准姿势。
迁移脚本里我毫不犹豫地选了--mirror。因为迁移不是开发,不需要工作区,也不需要一个一个处理分支,只需要一个"和源仓库完全等价的 .git 目录",然后把它整个推到新平台。
git clone --mirror https://gitee.com/yourname/project.git cd project.git git remote set-url origin https://github.com/yourname/project.git git push --mirror origin跑完这三条命令,GitHub 上的 project 仓库就会拥有和 Gitee 源仓库完全相同的全部分支和 tag。这就是整个迁移脚本最核心的引擎,其他所有代码都是在为这三条命令服务。
2.3 为什么不推荐"下载 zip 再上传"的土办法
我见过不少人迁移仓库用的是一个偷懒办法:在 Gitee/GitLab 网页上下载 zip 压缩包,解压后把文件批量传到 GitHub 的仓库里。这个办法对小项目、对只想要一份代码快照的场景或许够用,但在迁移场景下它有四个硬伤:
- Git 历史全部丢失。GitHub 上看不到任何 commit 记录,项目像刚被初始化一样只有一个 initial commit。
- Git LFS 大文件会损坏。很多仓库用了 Git LFS 管理二进制大文件,正常 clone 时 LFS 指针会指向实际存储的文件;但 zip 下载只会把指针文本下载下来,真正的大文件根本不在里面,传上去以后其他人 clone 下来看到的是几十字节的小文本而不是真正的资源文件。
- 分支和 tag 信息全丢。只有一个默认分支的代码快照,其他分支、历史标签一概不迁移。
- 原本可以做增量同步。如果以后源仓库又更新了几次 commit,zip 方式只能整个仓库再来一遍;用 mirror 方式则可以只需要再跑一次增量 push,几分钟就能同步完成。
单次迁移可能感觉不出差异,一旦涉及"周期性备份"或者"持续同步",mirror 方式的价值就彻底体现了。
2.4 脚本整体执行链路:API 拉列表 → 创建目标仓库 → 本地镜像中转 → 远程推送
整个脚本的运行流程可以这样概括:先从源平台(Gitee 或 GitLab)的 API 拉取你有权限操作的仓库列表,筛选出需要迁移的仓库;再调用 GitHub API 逐个创建同名仓库(私有/公开属性保持一致);然后对每个仓库依次执行 mirror clone、改 remote、mirror push。逻辑上只有四步,但每一步都有不少细节需要处理,比如分页、限流、仓库重名、空仓库、LFS、超时重试等。后面的章节我会逐个展开讲。
3. 三个平台 API 的衔接:列表获取与目标仓库创建这样写
3.1 环境准备与配置方式
脚本我是用 Python 写的,主要依赖requests库做 HTTP 请求,迁移的动作直接调用本机git命令,用subprocess完成。运行时只需要保证三件事:
- Python 3.6+ 环境,
pip install requests - 本机装了 git 并加入了 PATH(Windows 下注意用 Git Bash 自带的 git,或者把 git.exe 目录加入系统 PATH)
- 三个平台都准备好 Personal Access Token,写入环境变量,避免硬编码在脚本里
我个人强烈建议用环境变量传递 token,而不是在脚本里明文写死。因为这种脚本难免会发给同事、放到服务器上、或者推到自己的代码仓库里,一旦 token 泄露就等于把代码仓库的操作权限交了出去。
export GITEE_TOKEN="你的gitee私人令牌" export GITLAB_TOKEN="你的gitlab个人访问令牌" export GITHUB_TOKEN="你的github个人访问令牌" export GITHUB_USERNAME="你的github用户名"3.2 Gitee API:分页拉取用户拥有的全部仓库
Gitee 的 OpenAPI 是这三家里相对简单的,仓库列表接口是GET /api/v5/user/repos。它的分页参数是page和per_page,per_page最大 100,不传的话默认 20。我写了一个通用的分页函数,用"返回条数是否等于请求的每页条数"来判断是否还有下一页,避免死循环。
import os import requests from urllib.parse import quote GITEE_TOKEN = os.environ.get("GITEE_TOKEN") GITLAB_TOKEN = os.environ.get("GITLAB_TOKEN") GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN") GITHUB_USERNAME = os.environ.get("GITHUB_USERNAME") def get_gitee_repos(token): repos = [] page = 1 per_page = 100 while True: url = "https://gitee.com/api/v5/user/repos" params = { "access_token": token, "page": page, "per_page": per_page, } resp = requests.get(url, params=params) resp.raise_for_status() data = resp.json() repos.extend(data) # 经验:Gitee 返回数量小于 per_page 说明已经是最后一页 if len(data) < per_page: break page += 1 return reposGitee 的返回结果中,每个仓库对象包含name、path、html_url、private、fork等字段。我实际用的时候会再过滤掉 fork 的仓库,因为 fork 项目通常不是自己的原创内容,批量迁过去会污染 GitHub 账号。筛选条件是在后面加一句if repo["fork"]: continue。
3.3 GitLab API:membership=true 才能拿到全部项目
GitLab 的接口和 Gitee 略有差异,它拉项目列表用的是GET /api/v4/projects。这里有一个很容易踩的坑:不传membership参数的话,只会返回你创建的公开项目(或者你可能看不到的项目),传了membership=true才会把你作为成员参与的所有项目和群组项目都列出来。
def get_gitlab_repos(token): repos = [] page = 1 per_page = 100 while True: url = "https://gitlab.com/api/v4/projects" params = { "membership": "true", "per_page": per_page, "page": page, } headers = {"PRIVATE-TOKEN": token} resp = requests.get(url, params=params, headers=headers) resp.raise_for_status() data = resp.json() repos.extend(data) # GitLab 分页响应头里会有 X-Next-Page,也可以用它判断 next_page = resp.headers.get("X-Next-Page") if not next_page: break page = int(next_page) return repos需要注意两点。第一,GitLab 是分服务器版本的系统,我这段示例用的是 gitlab.com 公共平台的地址,如果你对接的是公司内网自建的 GitLab,把https://gitlab.com/api/v4/projects替换成https://你的gitlab域名/api/v4/projects即可。第二,GitLab 的分页不仅看返回条数,响应头里带着X-Next-Page字段,直接用这个字段翻页更可靠,这也是不同平台之间的差异点。
GitLab 项目对象里有path_with_namespace字段,表示项目的完整命名空间,比如group/subgroup/project。这个信息在创建 GitHub 仓库时会用上,因为 GitHub 仓库名不允许包含反斜杠,要决定是展平名称,还是用组织名替代分组名。
3.4 GitHub API:创建同名仓库时最重要的三个参数
目标仓库的创建调用的是 GitHub 的POST /api/v3/user/repos(注意 GitHub API 的版本根地址也有细微差异,现在推荐直接/api/v3或/api/v3前加https://api.github.com,实际上标准写法是https://api.github.com/user/repos)。
def create_github_repo(repo_name, private=False, token=None): url = f"https://api.github.com/user/repos" headers = { "Authorization": f"token {token}", "Accept": "application/vnd.github+json", } payload = { "name": repo_name, "private": private, "auto_init": False, } resp = requests.post(url, headers=headers, json=payload) if resp.status_code == 201: return resp.json()["clone_url"] elif resp.status_code == 422: # 仓库已存在,说明上次创建过,可以直接复用 return f"https://github.com/{GITHUB_USERNAME}/{repo_name}.git" else: resp.raise_for_status()这里最关键的是private参数。**很多人在写批量迁移脚本时最容易犯的错误,就是创建仓库时忘了把源仓库的公开属性同步过来。**源仓库是私有的,迁到 GitHub 变成公开,这等于直接把内部代码泄露了。我的策略是:从源平台 API 里读取每个仓库的private字段,Gitee 里是repo["private"],GitLab 里是repo["visibility"](值是 internal/private/public 字符串),然后透传给创建函数。
GitHub API 对仓库名的限制也比较严格,只允许字母、数字、-、_和.,而且会强制转成小写。如果 Gitee/GitLab 上有带大写字母的仓库名(GitLab 里很常见),需要先把名字转小写再调用创建接口,否则 GitHub 可能创建成功后发现名字跟预期不一致。
3.5 中继 URL:无论在哪个平台克隆,都要拼出带认证的完整地址
获取仓库列表和创建目标仓库只是搭好了"空壳",真正要把代码搬过去,得让 git 命令能够访问源仓库和目标仓库。这里就涉及 git 远程 URL 的认证信息拼接。
Gitee 这边,push/pull 时使用私人令牌的方式是把它放在 URL 里:
https://用户名:私人令牌@gitee.com/用户名/仓库名.gitGitLab(gitlab.com)官方推荐的令牌认证方式是:
https://oauth2:私人令牌@gitlab.com/用户名/仓库名.gitGitHub 这边,个人访问令牌可以充当基本认证密码:
https://用户名:令牌@github.com/用户名/仓库名.git注意这里有个经验:URL 里的用户名不是必须真实有效的。GitHub 只认 token 本身,用户名随便填什么都可以;Gitee 的令牌认证通常需要填真实用户名的;GitLab 的 oauth2 模式则必须写死oauth2这四个字符作为用户名。如果拼错,克隆可能没问题(公开仓库),但 push 的时候往往会报 403 或者 401。
用 token 拼 URL 的另一个问题是:这样一条 URL 如果被打印进日志,等于 token 直接泄露。所以我脚本里所有 remote 操作都避免输出完整 URL,日志里只打印平台名、用户名和仓库名。这也是安全习惯。
4. 推送阶段真正的坑:认证 URL、空仓库、LFS 与网络重试
4.1 核心迁移函数:mirror clone 与 mirror push 的标准姿势
拿到了源仓库的 clone 地址、拼好 GitHub 的 push 地址之后,就进入真正的迁移执行环节。这个函数是整个脚本里最值得反复打磨的部分:
import subprocess import tempfile import os import sys def migrate_one_repo(src_clone_url, github_clone_url, repo_name): # 1. 创建临时工作目录 workdir = tempfile.mkdtemp(prefix=f"migrate_{repo_name}_") try: # 2. 镜像克隆源仓库 clone_command = ["git", "clone", "--mirror", src_clone_url] result = subprocess.run( clone_command, cwd=workdir, capture_output=True, text=True, timeout=1800, ) if result.returncode != 0: print(f"[FAIL] {repo_name} clone失败: {result.stderr[-500:]}") return False # 3. 修改 origin 指向 GitHub repo_dir = os.path.join(workdir, f"{repo_name}.git") subprocess.run( ["git", "remote", "set-url", "origin", github_clone_url], cwd=repo_dir, check=True, ) # 4. 镜像推送到 GitHub push_result = subprocess.run( ["git", "push", "--mirror", "origin"], cwd=repo_dir, capture_output=True, text=True, timeout=3600, ) if push_result.returncode != 0: print(f"[FAIL] {repo_name} push失败: {push_result.stderr[-500:]}") return False print(f"[OK] {repo_name} 迁移完成") return True finally: # 5. 清理临时目录 subprocess.run(["rm", "-rf", repo_dir], capture_output=True)这个函数有几个设计细节值得说一下:
- 用
tempfile.mkdtemp给每个仓库生成独立的工作目录,避免多个仓库迁移时互相污染。 - clone 和 push 都加了一个较大的
timeout参数,防止某个仓库的钩子或者网络异常导致脚本卡死一整天。 - 捕获 git 命令输出,只在出错时把 stderr 最后 500 个字符打印出来,既保留调试信息又不会因为大仓库输出刷屏。
- 成功失败都打印一行明确的结果,这样整个批量跑完,扫一眼日志就知道哪些仓库成功、哪些失败、什么原因失败。
4.2 空仓库与只有初始提交的仓库怎么处理
git push --mirror有一个很容易迷惑新手的行为:如果源仓库是一个完全空的仓库(没有任何 commit,连初始提交都没有),--mirror推过去的时候 GitHub 端仓库虽然创建了,但会弹出一条提示"everything up-to-date",实际什么都没推。等你在 GitHub 网页上看,这个仓库会显示为空白,没有默认分支。
解决办法有两种。第一种是给空仓库造一个初始提交再推。但这会改变原仓库的历史结构,不推荐。第二种更优雅的方案是:迁移前先检查源仓库的默认分支是否存在;如果没有,就不执行 push 步骤,只创建 GitHub 上的空白仓库,并记录日志说明"源仓库原本就是空仓库,已自动跳过内容迁移"。
def check_repo_has_commits(clone_url): # 用一个超轻量的 ls-remote 检查远端是否有 refs/heads 或 refs/tags result = subprocess.run( ["git", "ls-remote", clone_url], capture_output=True, text=True, ) return len(result.stdout.strip()) > 0git ls-remote不会真的下载对象库,它只列一下远端有哪些 refs,速度极快。用它在 clone 之前做一次预检查,可以省下大量为"空仓库"白等的时间。
4.3 Git LFS 仓库的额外两步
如果你的源仓库用了 Git LFS(大文件存储),git clone --mirror只会把 LFS 指针文件复制过来,实际的大文件并不会被 pull 到本地缓存里。具体表现是:你拿到一个lfs/objects目录,里面几乎是空的。这时候直接push --mirror到 GitHub,GitHub 上看到的是一个个指向 LFS 资源的文本指针,而不是真实的大文件。
处理办法是在 push 之前先执行一次git lfs fetch --all,把源仓库关联的所有 LFS 对象下载到本地,push 时再用git lfs push --all origin推上去。注意这两条命令要分别执行,因为--mirrorpush 不触发 LFS 对象的传输,git push --mirror origin做完之后必须再补一发git lfs push --all origin。
def push_lfs_objects(repo_dir): subprocess.run( ["git", "lfs", "fetch", "--all"], cwd=repo_dir, check=True, ) subprocess.run( ["git", "lfs", "push", "--all", "origin"], cwd=repo_dir, check=True, )这是整个脚本里最容易被忽略的部分。我第一版脚本跑完一批仓库之后,发现有一个大型游戏资源的仓库在 GitHub 上体积异常小,才意识到 LFS 对象没推上去。从那以后我把"是否使用 LFS"作为迁移前的一个必查项,用git lfs ls-files看一眼仓库里有没有 LFS 跟踪的文件,有就走带 LFS 的完整流程。
4.4 网络中断与 Git 推送超时的兜底策略
批量迁移几十个仓库的时候,最烦的事情不是某一步操作的代码写错了,而是跑到一半网络抖动了:一个仓库克隆到 80%,error: RPC failed;另一个仓库 push 到一半连接被断开。Git 命令在非交互模式下碰到网络错误会直接退出,而脚本如果不管三七二十一直接把命令的 returncode 当作最终结果,那失败率会非常难看。
我的做法是给 git 命令增加重试机制,做一个简单的run_with_retry包装:
import time def run_git_with_retry(cmd, cwd, max_retries=3, timeout=1800): for attempt in range(1, max_retries + 1): try: result = subprocess.run( cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout, ) if result.returncode == 0: return result print(f"第{attempt}次尝试失败,返回码 {result.returncode}") if attempt >= max_retries: return result except subprocess.TimeoutExpired: print(f"第{attempt}次尝试超时") if attempt >= max_retries: return result time.sleep(3 * attempt) return result另外,Git 本身有一个很实用的配置项http.postBuffer。push 大仓库时经常会遇到"RPC failed; HTTP 413 curl 22"这类报错,根因是 HTTP/HTTPS 方式推送时,Git 会尝试把对象的传输封装成若干 HTTP 请求,默认缓冲区不够大时会失败。针对这种情况,可以在 push 前临时调大缓冲:
git config http.postBuffer 524288000这个值单位是字节,524288000 就是 500MB。不要小看这一句,我在迁移一些包含二进制资源的历史仓库时,靠它解决了不少莫名其妙的 mid-sentence disconnect 问题。
subprocess.run( ["git", "config", "http.postBuffer", "524288000"], cwd=repo_dir, check=True, )5. 迁移过程中我踩过的四个坑,以及完整排查链路
5.1 分页死循环:返回列表长度等于 per_page 不代表一定有下一页
第一版脚本跑的时候,我用了while True配合if len(data) < per_page: break的判断逻辑,想着"数量不满一页就是最后一页"。结果 Gitee API 那边出了个意料之外的情况:我账号下刚好有 200 个仓库,前两页每页 100 个,第三页理论上应该是 0 个,但 Gitee 居然返回了一个空列表。问题在于我当时是先把repos.extend(data)放在 break 判断之前,然后接着执行page += 1。这段逻辑本身没错,但后来我改代码调整了判断顺序,改成先判断再 extend,反而搞出了一个 page 永远不增长的死循环,折磨了我半小时。
排查链路也很典型:先看到脚本卡在某个仓库不走了,进程占用 CPU 极低,说明不是在做计算,而是卡在某个网络请求上;再看到请求日志里 page 永远等于同一个值,定位到是翻页逻辑的问题;最后把 extend 和 break 的顺序调整正确,问题解决。写分页代码的时候,最稳妥的范式是:
每轮先请求,再处理数据,再判断是否继续翻页。 至于综合判断方式:同时用"返回条数 < per_page"和"当前页大于最大假设值"两个条件做兜底。从那以后我在所有平台的 API 里都用"第三个条件强制保险":如果 page 超过 10000 还没 break,就当作异常退出,防止任何意外情况下的死循环。
5.2 GitHub 创建仓库 422:大小写、非法字符与重名的三重陷阱
迁移一个 GitLab 仓库时,源仓库名是MyProject,我照原样用name=MyProject去创建 GitHub 仓库,返回值直接是 422。GitHub 报错信息显示的名字被自动转换成了myproject,但 422 的根本原因我当时一度没想清楚——后来查了文档才明白,GitHub 仓库名创建时是不区分大小写的,而且创建成功后一律显示小写。如果账号下已经有一个myproject仓库,再创建MyProject就会直接被 422 拒绝,因为 GitHub 认为名字已被占用。
建议是所有仓库名在调用 API 前统一做name.lower()处理,另外用re.sub(r'[^a-z0-9_.-]', '-', name)把特殊字符替换成连字符。GitLab 和 Gitee 的仓库名允许的字符范围和 GitHub 不一样,比如 GitLab 允许中文名,但 GitHub 不允许,不提前规范化就会掉进 422 的坑里。
还有一个隐形问题:如果源平台有多个人都叫myproject(不同 namespace 下各有同名仓库),GitHub 上创建会冲突。我的处理是设置一个可选的命名前缀或者后缀参数,比如把group1-project变成group1__project,并在迁移报告里标记重命名情况。
5.3 push 时报 403 而非 401:认证方式与仓库权限边界
有一次迁移公司内部 GitLab 上的私有项目到 GitHub,clone 阶段一切正常,到了 push 阶段报了403 The requested URL returned error: 403。第一反应以为是 token 权限不够,检查后发现 GitHub token 是有repo完整权限的,而且其它仓库都能正常推。
后来我发现问题出在 GitLab 端推送的 URL 认证拼法上。我最初用的是https://用户名:令牌@gitlab.example.com/group/project.git,但 GitLab 服务端要求的是https://oauth2:令牌@...。403 的报错信息过于笼统,排查完才反应过来是认证 URL 的写法不合 GitLab 的口味。这提醒我,三个平台的认证协议并不完全统一,一定不能想当然地把 Gitee 秘制的 URL 拼接格式套到 GitLab 上。
另外还有一个容易 403 的细节:源仓库如果是在某个 group 下面,而你的 GitLab token 只对个人仓库有权限,对 group 仓库没有足够权限,也会在 push 的时候被挡下来。解决方案是检查 GitLab API 返回的permissions字段,确保你对要迁移的仓库至少有write_repository权限,没权限的仓库直接跳过并在日志里标记出来。
5.4 API 限流 403:Rate Limit 撞墙之后的优雅降级
批量迁移 50 个仓库时,我遇到过一次 GitHub API 返回403 rate limit exceeded,创建仓库的操作大批量失败。GitHub 的 API 限流规则是每小时 5000 次请求(带认证),照理说 50 个仓库也就是 50 次请求,不该撞墙。但我反复重试同一个仓库的创建时,把配额烧光了。
GitHub 的 403 响应头里会带X-RateLimit-Remaining和X-RateLimit-Reset字段,前者是剩余配额,后者是配额重置的 Unix 时间戳。遇到限流时,正确做法不是立刻重试,而是:
import time reset_time = int(resp.headers.get("X-RateLimit-Reset", 0)) sleep_time = max(reset_time - int(time.time()), 1) print(f"触发限流,休眠 {sleep_time} 秒等待配额重置") time.sleep(sleep_time)在批量脚本里加入这个限流感知逻辑以后,哪怕 API 配额被打满,脚本也会进入"等待"而不是"疯狂重试把配额越烧越光"的恶性循环。这个兜底机制在后来的多平台迁移中多次救人于水火。
6. 让脚本从"能用"到"好用":检查清单与扩展设计
6.1 运行前必查的四个维度
脚本写完之后,不要立刻一把梭跑全量。我的习惯是先做一轮干跑(dry-run),只打印执行计划、不真正执行 clone 和 push。等确认计划无误后,再挑一个最小的仓库做"冒烟测试",确认整条链路能跑通,最后才放全量。运行前必查的点:
- Token 权限是否到位:GitHub 的 token 至少勾选
repo,Gitee 的 token 勾选projects,GitLab 的 token 勾选read_api和write_repository。 - 磁盘空间是否够用:所有待迁移仓库的裸仓库体积加起来可能远大于你预期,尤其是有 LFS 的仓库,clone 后本地缓存会占用大量空间。脚本里最好在开头统计一下
git count-objects -vH或者直接看 clone 后的目录大小。 - 目标平台仓库配额:GitHub 免费账号对仓库总数没有硬性限制,但对单仓库大小有建议上限(GitHub 建议仓库小于 1GB,严格限制 100GB)。如果源仓库超大,要考虑是否使用 LFS 或拆分仓库。
- 迁移后的验证脚本:迁移完了别只看绿勾日志,建议额外跑一个比对脚本:用
git ls-remote分别列出源和目标的所有 refs,做一次 diff,确保分支和 tag 一个都不少。
6.2 增量同步与周期性备份的扩展思路
脚本写成型之后,我又做了几个方向的扩展,让它的适用场景从"一次性迁移"变成"持续同步"。最实用的一个扩展是:既然已经能通过 API 拿到源平台全部仓库列表,那每次执行时只需要比对"哪些仓库是新的、哪些仓库的 commit 落后于源平台",只对增量部分执行迁移操作即可。
具体实现上,可以在本地维护一个 JSON 文件,记录每个仓库上次迁移的 commit hash,用git rev-parse origin/HEAD就可以快速获取源仓库当前默认分支的 commit。这样每次跑脚本,大部分仓库根本不需要重新 clone,直接git fetch增量更新就够了。这个机制用来做跨平台定时备份,每周自动同步一次,效果非常好。
6.3 支持 Gogs、自建 GitLab 等其他平台的参数化
脚本写到这里,我发现了一个抽象共性:所谓迁移,本质上就是"从任意一个支持 Git 协议 + HTTP API 的平台,把仓库镜像到另一个平台"。不同的平台只是 API 的 base URL、认证方式、分页参数不同。所以我把源平台的几个关键差异抽成了配置项:
| 平台 | 获取仓库列表接口 | 认证方式 | 分页判断 |
|---|---|---|---|
| Gitee | /api/v5/user/repos | URL 参数access_token | 返回数量 < per_page |
| GitLab | /api/v4/projects?membership=true | HeaderPRIVATE-TOKEN | X-Next-Page响应头 |
| Gogs | /api/v1/user/repos | HeaderAuthorization | 返回数量 < per_page |
| GitHub | /user/repos | HeaderAuthorization | 返回数量 < per_page |
只要是符合 Git 托管协议的平台,换掉 base URL 和认证参数就能复用。我后来也就顺手加入了对 Gogs 等轻量自建平台的支持,配置文件里写清楚平台类型和 token,脚本主体逻辑完全不用改。
6.4 迁移后的验证清单与日常维护建议
迁移完成后不要立刻删掉源平台的仓库,先做一轮完整性验证:随机挑几个仓库 clone 到本地,检查默认分支的最新 commit 是否一致、tag 数量是否一致、Git LFS 文件能否正常拉取。我自己的验证步骤是写了一个verify.py,用git ls-remote --tags比对源和目标的 tag 列表,再用git rev-parse HEAD比对默认分支的 commit 哈希。两条比对都通过,基本可以判定迁移成功。
另外,如果迁移的是私有仓库,建议第一时间检查 GitHub 上仓库的 Settings,确认 visibility 正确。批量操作中难免有漏网之鱼,把"检查私有/公开属性"放在最后一步最稳妥。
整个脚本的代码量其实不大,但它把"反复手动搬运仓库"这种低价值劳动彻底消灭了。迁移完成之后,后续团队再产出的新仓库我只需要跑一次增量同步就能快速备份到 GitHub,整个工作流清爽了非常多。如果你也有类似的多平台仓库管理需求,与其继续在网页上一个个点,不如花一个下午把这个脚本写出来,一劳永逸的好事值得做。