说实话干这行最烦的一件事就是代码明明已经写完了还得挨个平台去推送。我手上有几个开源项目GitHub放主仓Gitee放国内镜像公司内部GitLab还要有一份方便同事引用。每次提交代码至少要敲三遍push敲完还要担心是不是漏了哪个分支、哪个tag。后来我索性花了点时间把手动推送升级成一套多平台仓库同步tools彻底摆脱了人肉同步的尴尬。这篇文章就把我调研的方案、踩过的坑、最终落地的配置完整写出来希望能给正被仓库同步问题折磨的同行一点参考。1. 为什么仓库同步总是让人头疼1.1 一个项目挂多个平台是真需求还是自找麻烦先聊聊需求来源。很多人可能觉得代码放在一个平台不就完了吗干嘛非要折腾多个平台。但实际工作中多平台托管的需求非常真实。最常见的场景是开源项目主仓库放在GitHub方便国外开发者提PR、提issue但对国内用户来说访问体验一般于是再弄一个Gitee镜像仓库让国内用户能快速下载和参与。还有一种场景是公司内部项目对外开源一部分代码内部还保留一个私有仓库做进阶功能两个仓库之间需要保持同步。再加上有些团队把代码托管在自建的GitLab上又要跟公有平台的仓库打通。同一个项目两三个平台的托管地址是很常见的事。多平台托管本身不是问题问题出在同步上。刚开始我采用的是最原始的方法每次提交完手动push多个remote。一个人短期用还可以但超过一个月就出事了——今天只记得推master忘了develop明天漏了一个打了半天的tag后天发现某个平台上的代码版本落后了两个commit。等到想去核对哪个仓库才是最新的时已经需要靠commit message和时间戳去猜了。更头疼的是多人协作场景。我推了最新代码到GitHub同事在Gitee上fork了一份基于旧代码做了修改两边代码开始分叉。这时候你再想靠手动push维持同步基本是不可能的。所以多平台托管不是自找麻烦它是真实存在的协作需求真正让人头疼的是缺少一套稳定可靠的同步机制。1.2 仓库同步的本质引用、对象与增量要解决同步问题得先搞明白Git仓库同步到底在同步什么。一个Git仓库从底层看就是一堆对象的集合commit对象记录提交信息和父提交关系tree对象记录目录结构blob对象存储文件内容。光有这些对象还不够仓库还需要引用refs来指向当前处于哪个提交——分支是refs/heads/标签是refs/tags/HEAD则指向当前检出的分支。所以仓库同步的本质很简单把源仓库的引用状态以及这些引用所指向的对象完整复制到目标仓库。这句话听起来平平无奇但理解了它就理解了后面所有优化方案的出发点。举个例子git clone --mirror能完整地把所有分支、标签和对象都拷过来但它是一次性的。克隆完成之后源仓库又有了新提交目标仓库并不会自动更新。我们需要的是一套能持续运行的机制每次只搬运变化的部分。这个变化的部分就是Git内部的增量对象理解了增量同步的概念你就能明白为什么第一次全量推送可能耗时很久之后每次增量同步却往往只需要几秒钟。1.3 评判同步方案好坏的三个维度我选同步方案时一般只看三个指标这三个指标基本可以判断一个方案是临时应急还是长期可用。第一个是增量能力。每次同步是不是只推送新增的提交和对象如果每次都是全量推仓库一变大就撑不住。第二个是自动化程度。是每次手动敲命令还是push之后自动触发自动化程度决定了这个方案能不能在团队中推广会不会因为某个人忘了执行而断链。第三个是可观测性。同步成功有没有反馈失败了能不能第一时间看到日志和告警三个指标都满足才是一套称手的仓库同步tools。这三个维度也决定了下面的方案选型。你会发现越简单的方案前两个维度上表现越差越复杂的自研工具对第三个维度支持越好。没有绝对的好方案只有适不适合你的场景。2. 多平台仓库同步的主流方案选型2.1 多remote直推一条命令推进多个平台最直接的做法是给本地仓库配置多个remote地址然后一次push到所有平台。具体有两种配置方式。第一种是分别添加多个remote推送时逐一执行git remote add gitee gitgitee.com:yourname/repo.git git remote add gitlab gitgitlab.com:yourname/repo.git git push gitee --all git push gitlab --all这种方式逻辑简单每次想推哪个平台就推哪个但命令还是有点多。第二种方式更黑科技一点用set-url的追加模式把一个remote的push地址同时指向多个平台git remote set-url --add --push origin gitgithub.com:yourname/repo.git git remote set-url --add --push origin gitgitee.com:yourname/repo.git配置完之后执行git push origin masterGit会同时往GitHub和Gitee推送。多remote直推的优点是真的简单不依赖任何第三方工具两条命令就搞定。但它的缺点也很致命第一每次都需要本地执行忘了推没人提醒第二删除分支和强制推送这类危险操作会被复制到所有平台误操作的风险翻倍第三如果其中一个平台网络抖动导致推送失败Git会停在半路你很难判断哪些平台成功了哪些失败了非常尴尬。所以这个方案适合个人项目临时用不建议作为团队长期方案。2.2 CI/CD自动同步push之后什么都不用管既然手动执行容易忘那就把同步动作交给CI/CD流水线。思路很简单在托管平台的CI服务里配置一个流水线监听push事件一旦源仓库有新提交自动把代码推送到其他平台。以GitHub Actions为例你可以写一个workflow挂在push事件上执行一条git push到Gitee的命令。这种方式最大的优点是自动化程度非常高提交完代码同步动作由流水线自动完成不需要任何人记着去执行。其次可观测性好CI的job状态一目了然失败了还能配置邮件或IM通知。缺点是它依赖平台自身的CI服务。GitHub Actions对开源项目比较友好免费额度够用但如果你用的是公司内网GitLab或者自建的CI系统维护成本就得另算。还有一个容易被忽视的问题是CI运行环境的网络链路——你的CI跑在哪个平台要往哪个平台推这中间的连通性直接决定同步能不能成功。选方案之前一定要先确认这条链路是通的、稳定的。2.3 自研同步脚本灵活可控的终极方案当需求变得复杂比如需要从多个源仓库同步到多个目标仓库、需要按目录筛选内容、需要在同步前后做数据转换CI方案可能就不够用了。这时候最靠谱的方式是自研同步脚本或工具。一个合格的同步脚本至少要解决四件事如何获取源仓库最新的引用用git ls-remote还是git fetch推送到目标仓库时用什么认证方式HTTPSToken还是SSH Key同步日志怎么写方便追溯失败原因增量同步怎么实现避免每次全量推送浪费时间和流量。我的经验是不要一上来就写大而全的框架先用一个简单的脚本把核心流程跑通再逐步加入异常处理和日志模块。脚本的好处是可以在任何环境运行——本地定时任务、服务器cron、CI的某个job都能塞进去。下面第三章会给出一个可参考的Python实现。2.4 选型建议不同场景适合不同方案直接给结论。个人项目或小团队用多remote直推最高效但一定要给自己定好每日推送清单之类的习惯防止漏推。开源项目需要稳定自动同步的优先用CI流水线方案维护成本最低可观测性也最好。如果涉及跨团队、跨多平台、有复杂同步规则的才需要考虑自研工具。还有一个判断标准是反向需求你是从国外平台往国内平台同步还是从国内往国外。由于网络链路问题同样的工具在两个方向上的稳定性可能完全不同。选型时一定要结合实际运行环境测试一下不要想当然。3. 实操从零搭建仓库自动同步工具3.1 前置准备Token创建与权限配置不管用哪种方案身份认证都是第一步。这里我强烈建议优先使用HTTPSTokenPersonal Access Token的方式而不是SSH Key。原因有两个Token可以精确控制权限范围失效后单独吊销不影响其他操作在CI环境中注入Token更方便不需要处理SSH Key的拷贝和权限位问题。GitHub侧你在Settings - Developer settings - Personal access tokens - Tokens (classic)里创建一个新Token。如果你是同步私有仓库一定要勾选repo这个权限否则同步时无法读取私有仓库的内容。如果你的源仓库里包含workflow文件并且想让目标平台也保留这些workflow那还需要勾选workflow权限。Gitee侧路径是设置 - 私人令牌创建时需要勾选projects权限。Gitee的Token可以设置较长有效期GitHub的Token建议90天或180天尽量开启自动续期减少人工介入。注意Token属于敏感信息绝对不能直接写在仓库代码或workflow文件里。GitHub Actions用Secrets保存本地脚本用环境变量读取。这个习惯一定要从第一天就养成不然哪天Token泄露到公开仓库麻烦就大了。3.2 GitHub Actions自动同步完整配置下面是我在项目里实际用过的workflow配置核心思路是监听master/main分支的push以及tag的创建然后自动把代码同步到Gitee。name: sync-to-gitee on: push: branches: [master, main] create: tags: [*] jobs: sync: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkoutv4 with: fetch-depth: 0 - name: Sync to Gitee env: GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }} run: | git remote add gitee https://yourname:${GITEE_TOKEN}gitee.com/yourname/repo.git git push gitee --tags --force git push gitee --all --force拆解几个关键点。fetch-depth: 0是必须写的。GitHub Actions默认的checkout只会拉取最新一次提交深度为1。如果你不加这个参数后面git push gitee --all --force推上去的仓库会缺少完整历史变成浅克隆状态。加了fetch-depth: 0才能保证全量历史被拉取下来。--force建议加上因为同步是单向的GitHub - GiteeGitee仓库只是一个镜像强制推送可以保证目标仓库和源仓库完全一致。如果源仓库发生rebase或force push普通推送会报rejected加了--force则能强制把目标仓库刷新过去。如果担心误伤可以去掉--force但一旦遇到历史改写你还需要手动介入处理。同步顺序也有讲究先推tags再推--all。为什么因为tag通常指向某个commit如果你先推分支再推tag理论上也没问题但如果tag指向一个还没有推送到目标仓库的commitgit会在目标仓库创建这个tag时失败。先推tags再推分支能保证tag引用的commit对象已经就位。3.3 分支删除与强制同步的细节处理上面workflow只用到了push事件和create事件但如果你在GitHub上删除了一个分支Gitee上的对应分支并不会被自动删除。这是因为git push gitee --all --force只推送本地存在的分支目标仓库中多余的分支它不会管。要解决分支残留问题有两个思路。第一个思路是用git push --mirror替代--all。--mirror会把本地refs的状态完全镜像到远程包括删除远程不存在的分支。但--mirror在非裸仓库上使用有一些限制CI环境里通常需要先把本地仓库转成裸仓库操作略繁琐。第二个思路更简单在GitHub Actions里用--prune选项配合推送。git fetch --prune origin refs/heads/*:refs/heads/* git push gitee --force --all --prunegit push --prune会删除远端存在但本地不存在的refs与--all配合时可以清理掉那些在源仓库已经被删除的分支。实测下来这个组合命令在同步镜像类仓库时很有效。我遇到过的最极端情况是一次分支重构删了十几个feature分支如果不用pruneGitee上会残留一堆僵尸分支看起来非常混乱。如果你还需要监听分支删除事件可以在workflow的on块里增加delete事件。不过要注意单纯监听delete事件并触发job和上面的--prune方案并不冲突两者配合能达到删除操作即时同步的效果。3.4 自研Python同步脚本核心实现如果你的场景不依赖CI平台可以写一个独立的同步脚本。下面这个Python版本是我常用方案的简化版它的职责很纯粹从源仓库拉取最新代码推送到多个目标仓库并输出清晰日志。#!/usr/bin/env python3 import os import subprocess import logging from datetime import datetime logging.basicConfig( levellogging.INFO, format%(asctime)s %(levelname)s %(message)s ) SOURCE_REMOTE os.environ.get(SOURCE_REMOTE, gitgithub.com:yourname/repo.git) TARGET_REMOTES [ os.environ.get(GITEE_REMOTE, gitgitee.com:yourname/repo.git), os.environ.get(GITLAB_REMOTE, gitgitlab.com:yourname/repo.git), ] def run(cmd, checkTrue): logging.info(Executing: %s, .join(cmd)) result subprocess.run(cmd, capture_outputTrue, textTrue) if check and result.returncode ! 0: raise RuntimeError(fCommand failed: {result.stderr}) return result.stdout.strip() def sync_repo(workdir): os.makedirs(workdir, exist_okTrue) os.chdir(workdir) if not os.path.exists(.git): run([git, clone, --mirror, SOURCE_REMOTE, .]) else: run([git, remote, update, --prune]) for target in TARGET_REMOTES: try: run([git, push, --mirror, target]) logging.info(Synced to %s OK, target) except RuntimeError as e: logging.error(Sync to %s failed: %s, target, e) if __name__ __main__: workdir os.environ.get(REPO_WORKDIR, /tmp/repo-sync) sync_repo(workdir)这个脚本的核心是用clone --mirror维护一个裸仓库每次同步前用remote update --prune拉取源仓库的最新引用再通过push --mirror把目标仓库完全镜像成源仓库的状态。几个值得注意的细节第一--mirror克隆出来的是裸仓库没有工作区不会有工作区文件冲突非常适合做纯同步。第二remote update --prune等价于git fetch --all --prune会更新所有远程跟踪分支并删除失效分支。第三日志一定要打全。我第一版脚本没有日志某段时间同步失败后排查了很久才发现是Token过期后来给每一条命令都打了日志排查效率直线上升。如果你需要把这个脚本跑成服务可以配合cron定时执行比如每小时跑一次*/30 * * * * source /opt/sync_env.sh python3 /opt/repo_sync.py /var/log/repo_sync.log 213.5 监控与告警别让同步工具成为新的黑盒同步工具搭建完之后很多人会犯一个错误把它当成一次性的配好就不管了。这恰恰是最危险的。一个没有监控、没有日志、没有告警的同步工具本质上是一个新的不可见故障点——你会以为所有平台都是最新的实际上某些平台早就落后了。我的做法有三层。第一层是日志所有同步执行都会写到独立文件或收集到日志系统这样至少能追溯哪个时间点同步了什么。第二层是主动校验定期用git ls-remote对比源仓库和目标仓库的HEAD commit SHA是否一致不一致就说明同步链路出了问题。第三层是告警日志中一旦出现error级别信息立刻推到IM群。简单说让你的同步工具有迹可循。4. 高频问题排查与避坑指南4.1 分支推了标签却全丢了这是新手最常踩的坑。很多人用git push gitee --all同步分支但忘了加--tags。Git的--all只推分支引用不会推标签引用标签必须单独处理。解决方法是同步命令里同时带上git push gitee --all --tags --force如果你想更精确地控制同步范围可以写refspecgit push gitee refs/heads/*:refs/heads/* refs/tags/*:refs/tags/*refspec里的加号表示允许强制更新效果等同于--force。4.2 大文件和LFS同步失败如果源仓库启用了Git LFSLarge File Storage直接push到目标仓库时会发现LFS指针文件被推送过去了但真正的大文件对象还在LFS服务器上。目标平台如果不支持LFS其他人在目标仓库里下载代码时只会拿到一些文本指针真正的内容拉不下来。Gitee对LFS的支持策略跟GitHub不完全一样GitHub对LFS有免费额度Gitee则需要开通对应的服务。遇到这种情况我的处理思路有两种。一是把大文件从代码仓库里剥离出来单独走对象存储仓库里只保留小文件或引用文件这样同步压力小目标平台也不会出问题。二是如果无法改变架构就在同步说明里明确标注这个仓库是纯镜像大文件请从源仓库获取。这两种方式各有利弊核心是别让同步工具默默把残缺的仓库当成正常产物推向其他平台。4.3 Token失效的隐蔽原因认证失败时错误信息通常是fatal: Authentication failed或remote: Invalid username or password。排查步骤其实不复杂先确认Token本身是否还有效去平台设置页重新生成一个测试再用带Token的URL手动执行一次push看具体报什么错检查Token权限范围比如GitHub Token没有repo权限私有仓库的读取会直接失败。但有一个很隐蔽的问题我专门提醒一下如果你在CI环境里使用Secret一定要检查Secret尾部是否有多余的换行符或空格。我自己就遇到过这种情况GitHub Actions的Secret里保存Token时末尾多了一个换行导致认证一直失败排查了两个多小时才发现。这种问题的最快排查方式是在workflow里加一步echo ${{ secrets.XXX }} | xxd | tail -3直接看字符的十六进制编码一眼就能发现不可见字符。4.4 强制推送与历史改写如果源仓库有人执行过git rebase或git reset --force历史提交的SHA值会发生变化。此时目标仓库保留的还是旧历史普通push会提示! [rejected]因为远程存在本地没有的提交。处理方式取决于你的同步策略。如果以源仓库为准直接用强制推送让目标仓库完全跟随源仓库。如果以目标仓库为准那同步工具会失败因为强行推送会覆盖目标仓库独有提交。这种情况需要人工介入判断哪些提交只在目标仓库存在决定是否合并或丢弃。我的建议是凡是用做镜像的同步统一走强制推送并约定源仓库是唯一权威来源。同步工具不是合并工具它不需要处理冲突只需要忠实复制。如果你需要的是双向合并那已经不是同步问题而是多仓库协作问题了解决方案要复杂得多。4.5 CI运行时长超限CI流水线是有运行时长限制的。GitHub Actions免费额度下单次job有6小时的限制。听起来很长但如果仓库历史非常大比如几十GB第一次全量同步真的可能跑超时。我的解决办法是第一次全量同步在本地或服务器上手动完成等目标仓库已经拥有完整历史之后再让CI只负责增量同步。增量同步只需推送新增的对象通常几秒到几分钟就能完成。我在做大仓库迁移时第一次push --mirror跑了1个多小时之后每次稳定在1分钟内靠的就是这一点。结尾写到最后说说我自己的体会吧。多平台仓库同步不是个多高深的技术问题但绝对是个细节决定成败的工程问题。从方案选型、Token管理、分支策略到日志监控每一环都得认真对待。我见过太多人用了一个月的多remote直推之后因为漏推某个分支导致线上发布用了旧代码最后灰溜溜地排查了一下午。如果你正在被仓库同步折磨别急着上复杂的工具先从多remote直推开始再换成CI或自研脚本跑通一套自动同步最后把监控和日志补齐。我现在已经基本不关心同步这件事了——主仓库push完同步工具自动干活我最多隔几天翻一眼同步日志看看有没有异常。这才是一套称手的多平台仓库同步tools该有的样子。
