
GitHub 访问不稳定Gitee 又缺最新代码两边各有一版提交记录维护起来一个头两个大——这是我做 gitee 与 github 双向代码同步的起因。折腾了小半年踩了不少坑也沉淀出一套相对稳定的方案今天把整个设计思路、配置文件、脚本逻辑和注意事项都写出来希望能帮到同样有双平台维护需求的朋友。这个方案不依赖任何第三方镜像服务只靠 Git 自带能力、GitHub Actions 和两个平台的 Token 就能跑起来适合个人开发者和小团队直接抄作业。1. 为什么要做双向同步单向镜像方案并不够用1.1 我遇到的实际场景我手上有一个开源项目主仓库放在 GitHub 上原因很简单Issue、PR 流程、Actions 生态都更成熟。但国内很多用户访问 GitHub 不稳定于是我在 Gitee 上也建了仓库把代码同步过去方便国内用户克隆和下载 Release。一开始我用的是最简单的方式——每次本地提交完手动往两个远程仓库分别推一遍git push github main git push gitee main刚开始还挺顺利直到有一天一个同学在 Gitee 仓库上直接提了一个 PR我在页面上点了合并。几小时后我被自己写的一行代码坑了本地main分支不知道 Gitee 上已有新提交下一次我还原本地代码后直接强推到 Gitee把那个 PR 的提交覆盖了。那一刻我意识到单纯靠“人肉同步”早晚会出事必须有自动化机制并且要能清晰判断“哪一边更新、哪一边是权威、两边分叉时怎么办”。如果你只是把 Gitee 当 GitHub 的只读镜像那单向同步就够了——GitHub 动一下自动往 Gitee 推。但真正让我决定做双向同步的是团队协作方式的转变有人习惯在 GitHub 上提 PR也有人因为网络原因更愿意在 Gitee 上操作两边都可能会出现新的提交。这个时候必须让“任何一边的改动最终都能落到另一边”这就是双向代码同步要解决的问题。1.2 单向推送与双向同步的本质区别单向同步本质上是“单向流动”源头动了把源头的最新提交推到目标端。即使目标端有人提交了东西通常也会被强制覆盖因为设计上就把目标端当作“只读镜像”。双向同步就完全不同了。它更像是两个独立的权威仓库之间互相同步任何一边都不允许被静默覆盖。这意味着你需要知道两边各自的HEAD分别指向哪个 commit。你需要判断“我的本地新提交是否已经包含对方的最新提交”。你还要处理一种最麻烦的情况两边在同一时刻基于同一个祖先分别产生了新提交也就是分叉diverged。打个比方单向同步像是你在自己的笔记本上做记录然后复印一份贴在公告栏双向同步像是两个办公室共用同一份档案谁都能在上面补充内容但你不能随便把别人刚写的那页撕掉。1.3 双向同步必须面对的两大问题第一个问题是“谁来触发同步”。GitHub 有现成的 Actions 可以做事件触发Gitee 也有 WebHook 可以在 push 时通知外部服务。但两边互相触发如果不加保护就会出现 A 推给 BB 触发回调又推给 AA 再触发回调推给 B……我管这叫“回环风暴”。后面会详细解释我怎么绕开它。第二个问题是“冲突了怎么处理”。这是所有双向同步方案里最核心的决策点。我的策略是当检测到两边出现“分叉”时停止自动同步通知人去手动处理。听起来很怂但这才是生产环境里最稳妥的做法。自动合并代码在冲突场景下太危险了尤其是两边同时改了同一段配置或同一份文档机器没有能力判断谁是对的。2. 双远程架构与令牌准备把“同步”拆成两段互不干扰的流水线2.1 为什么是双远程而不是一个镜像仓库我的方案核心是在执行同步的机器上同时持有两个远程地址一个是 GitHub 的origin另一个是 Gitee 的远程仓库。每次同步不是直接“仓库对仓库”这么抽象而是通过本地 Git 完成一次“中转”。具体来说同步动作的本质是两步从源端fetch最新提交。把自己缺少的提交追加到本地对应分支然后push到目标端。为什么不直接用 Gitee 的“仓库镜像”功能做双向因为镜像功能的定位是“单向拉取”而且同步时机不可控失败日志看不清楚对于需要强一致性的双主场景不够可靠。自己掌握同步脚本至少你能精确控制“什么时候同步、同步哪些分支、冲突时怎么办”。2.2 Gitee 私人令牌与 GitHub Token 的权限配置要让 GitHub Actions 这台“自动机器”能代表你推代码需要给机器发一个“通行证”。Gitee 这边我用的方式是私人令牌私人令牌在 Gitee 网站右上角头像菜单里进入“设置 → 安全设置 → 私人令牌”。创建时记得勾选projects权限因为只有这个权限能让你通过 HTTPS 推送仓库如果以后还想用 WebHook 或者 Gitee Go可以顺手勾上hooks。令牌只显示一次创建后立即复制保存。GitHub 这边如果你只在 Actions 内推送代码其实不需要创建自己的 PAT用 GitHub 自动注入的GITHUB_TOKEN就够了。它的权限由 workflow 里的permissions字段控制我一般显式声明permissions: contents: write这里有个非常关键的点GitHub 官方文档规定使用GITHUB_TOKEN执行 push 时不会触发新的 workflow run。换句话说我在反向同步脚本里用它把代码推回 GitHub不会导致仓库里其他监听 push 的 workflow 反复执行天然避免了自己触发自己的问题。如果换成用自己的 PAT则可能造成循环触发一定要注意。再补充一个本地开发时容易忽略的细节如果你本地要同时管理 GitHub 和 Gitee 两个远程建议在~/.ssh/config里为主机名做区分避免 SSH key 混用。但话又说回来我最终让 Actions 统一走 HTTPS Token 的方案因为 Token 可以直接放进 secrets不用在 CI 环境里为 SSH key 绕来绕去。2.3 “最新写入者胜出”的同步策略我最终采用的策略可以概括为以分支的提交历史关系为准能快进就快进不能快进就停下来。具体判断逻辑围绕“谁是谁的祖先”展开。假设我在同步main分支本地仓库里的HEAD代表当前 GitHub 上的状态因为 Actions 是在 GitHub 仓库里跑的checkout 的就是 GitHub 分支。我fetch一个名为gitee的远程拿到gitee/main指针。如果gitee/main是HEAD的祖先说明 Gitee 没有本地还不包含的新提交放心把HEAD推过去。如果HEAD是gitee/main的祖先说明 Gitee 上有新提交但本地还没有合入。这时候应该反过来把 Gitee 的提交拉回来再推上去。如果两者各有对方不包含的提交即分叉停止同步并报错。这个策略有明确的取舍它不追求“合并双方改动”而是“最新写入者胜出”。只要有人在一边改了东西另一边还没跟上就会把改动的提交同步过去如果两边同时基于同一个旧版本改了东西那就必须有真人介入。这种设计牺牲了极限情况下的自动化和智能性换来了可预期的行为对生产环境来说非常值得。3. GitHub → Gitee 方向Action 定时 事件触发核心 workflow 拆解3.1 workflow 整体结构GitHub 出发推送 Gitee 的方向我用了一个专门的工作流文件放在仓库的.github/workflows/sync-to-gitee.yml。它同时监听两件事push 事件以及定时任务。name: Sync to Gitee on: push: branches: [main] tags: [*] schedule: - cron: */10 * * * * permissions: contents: write jobs: sync: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkoutv4 with: fetch-depth: 0 - name: Configure git run: | git config user.name sync-bot git config user.email sync-botusers.noreply.github.com - name: Sync branches to Gitee env: GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }} run: | git remote add gitee https://your_name:${GITEE_TOKEN}gitee.com/your_name/your_repo.git git fetch gitee --prune || echo 首次同步远端不存在对应分支 if git merge-base --is-ancestor gitee/main HEAD; then git push gitee --force-with-lease main:main else echo Gitee 侧存在未同步或分叉的提交跳过自动推送 exit 1 fi - name: Sync tags and deletions if: always() run: | git push gitee --tags --force你可能会问为什么有了 push 触发还要加一个schedule定时任务原因有两个。第一某些修改不是通过 push 产生的比如在 GitHub 网页上直接编辑文件、合并 PR这些操作严格来说也会触发 push 事件所以大部分情况下能捕捉到但还有一种情况仓库的默认分支如果被平台内部操作更新可能出现事件丢失或 Actions 排队延迟定时任务就是兜底。第二定时任务也承担了另一个职责即使在没有任何人推送时它也会去检查 Gitee 的最新提交为反向同步方案提供补充。fetch-depth: 0必须是 0否则 checkout 只会拿到最新一层的浅克隆见证远程分支关系时会因为缺少祖先历史而判断失败。3.2 同步脚本的核心判断逻辑最关键的判断是git merge-base --is-ancestor gitee/main HEAD这句话。它能精确回答“Gitee 的 main 是不是已经在本地 HEAD 的历史里了”。是则代表可以安全推送不是则说明 Gitee 上可能有新的提交或者历史已经分叉。至于为什么推送时用--force-with-lease而不是--force这里我要多说两句。--force是“我不管远程现在长什么样直接覆盖”--force-with-lease是“只有当远程的引用仍然是我之前看到的样子时我才会覆盖”。在多人协作或同步机制不完善时后者能避免“你不知情的时候别人往 Gitee 推了东西然后被这台机器默默覆盖”的惨剧。第一次同步时如果gitee/main不存在--force-with-lease也能正常工作因为 lease 要求的是引用没有在我 fetch 之后发生变化。此外脚本里我先用了git fetch gitee --prune。--prune会删除远端已经消失的远程跟踪分支引用避免残留的旧引用影响后续判断。首次同步时 Gitee 远端是空仓库fetch 可能会失败所以我加了|| echo让脚本继续执行然后直接执行 push 创建分支。3.3 分支、标签与删除操作的同步如果项目只有 main 分支前面的代码够用了。但我实际项目中还有dev分支和一堆 tag所以还得处理三件事第一多分支同步。你需要给每个分支写类似逻辑或者把refspec改成通配git push gitee --all --force-with-lease但我不建议无脑--all。它会同步仓库里所有本地分支包括临时分支、实验分支容易把不该出现的东西带到 Gitee。我更倾向于显式列出需要同步的分支比如main、dev、release在脚本里分别判断。第二tag 的同步。前面 workflow 里用了git push gitee --tags --force。这个命令会把本地所有 tag 推到 Gitee--force则是允许覆盖同名的 tag。Git 的 tag 默认是不可变的如果你重打了一个同名 tag不带--force会直接报错。这里我故意用--force而不是--force-with-lease因为 tag 的覆盖场景里lease 并不总能提供足够保护——但这也意味着你推送 tag 时要格外小心。第三分支删除的同步。GitHub 上删除了一个分支Gitee 上不会自动跟着删。因为本地fetch --prune只是清理了远程跟踪引用并不会删除目标仓库的远端分支。要同步删除操作需要显式执行git push gitee --delete old_branch_name我目前的做法是删除分支这类高风险操作不放进自动同步脚本而是由人工在两边分别操作或者手动跑一条删除命令。自动同步的好处是省事但遇到删除强制覆盖这类场景多一道人工确认反而更安全。4. Gitee → GitHub 方向用轮询替代 WebHook避开回环风暴4.1 为什么反向不能简单接入 WebHook反向同步也就是 Gitee 的提交要同步回 GitHub最直觉的方案是给 Gitee 仓库配置 WebHook别人在 Gitee 上 push 一下Gitee 就发一个 HTTP 请求到你的服务你收到后执行同步脚本。这个思路本身没错但有个现实问题WebHook 需要一个公网可达的接收端。如果我是用 GitHub Actions 完成同步Actions 没有常驻的公网监听端口。有人会用一个云函数或者公网服务器接收 Gitee 的 WebHook 请求然后调用 GitHub API 触发 workflow 的repository_dispatch。这当然可行但引入了一个新的维护点云函数要部署、要配鉴权、要处理 Gitee 签名验证。对个人开发者来说为了代码同步专门搞一套公网服务有点重了。更致命的是回环问题。当 GitHub → Gitee 的同步推送到 Gitee 后Gitee 的 WebHook 会认为“Gitee 发生了 push”如果我配置了反向同步逻辑它就会把这次推送当成 Gitee 上的新提交再触发一次 GitHub → Gitee 或 Gitee → GitHub 的同步来回反复。虽然可以通过请求头或 commit message 做来源标记但这些方案都会让架构复杂化。所以我的选择是反方向用定时轮询。GitHub Actions 本身支持schedule我让它每隔一段时间去 Gitee 拉取最新提交然后合并回 GitHub。这个方案不需要任何公网服务也不会因为自己的同步行为触发新的反向同步循环。代价是同步延迟最坏情况下会隔一个轮询周期才能把提交带回来但对绝大多数项目来说这个延迟完全可接受。4.2 轮询方案的具体实现第二个 workflow 文件.github/workflows/sync-to-github.yml长这样name: Sync Gitee to GitHub on: schedule: - cron: */10 * * * * permissions: contents: write jobs: sync-back: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkoutv4 with: fetch-depth: 0 - name: Fetch from Gitee and merge env: GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }} run: | git remote add gitee https://your_name:${GITEE_TOKEN}gitee.com/your_name/your_repo.git git fetch gitee main if git merge-base --is-ancestor HEAD gitee/main; then echo Gitee 没有新提交直接结束 exit 0 fi if git merge-base --is-ancestor gitee/main HEAD; then echo GitHub 已经包含 Gitee 的新提交无需处理 exit 0 fi echo 检测到 Gitee 领先开始合并 git merge gitee/main --no-edit --allow-unrelated-histories || exit 1 - name: Push back to GitHub run: git push origin main这个脚本的判断逻辑和正向同步其实是镜像的如果HEAD是gitee/main的祖先说明 Gitee 出现了本地不包含的新提交可以合并。如果gitee/main是HEAD的祖先说明本地已经包含对方的新提交什么都不用做。如果都不是代表分叉git merge可能会冲突合并失败就退出等人工处理。这里用git merge而不是 rebase原因是我只关心把 Gitee 的新提交带回来不希望重写 GitHub 侧的已有提交。合并会产生一个Merge remote-tracking branch gitee/main的提交虽然会多一个 merge commit但保留了完整历史而且不会因为 rebase 改写导致其他同步方向再次检测到差异。关于GITHUB_TOKEN推送回 GitHub 时workflow 里的git push origin main使用的凭证是系统自动配置在actions/checkout里的GITHUB_TOKEN。正如前面说的用这个 token 推送不会触发新的 workflow所以不会和正向同步 workflow 之间形成无限循环。我特意没有用自己的 PAT就是利用了这个机制。4.3 合并策略的选择有读者可能会问既然 GitHub → Gitee 方向是“能快进就推不能快进就退出”为什么反向却用git merge而不是同样的“不能快进就退出”因为两个方向的性质不同。正向同步是从 GitHub 出发本地 HEAD 通常情况下就是最新的权威状态如果 Gitee 有分叉最安全的处理是“我不覆盖你你自己处理”。反向同步是从 Gitee 出发Gitee 上可能有用户在网页端直接改代码产生的提交。如果我拒绝合并这些提交就永远无法回 GitHub。所以在反向这里我给了 Gitee 侧一个新提交的“合并机会”——只要历史能干净合并就让它合入如果分叉导致冲突才停下来。要注意的是这个合并是在 Actions 的临时环境里完成的它产生的结果会 push 到 GitHub。下一次正向同步检测时会发现 GitHub 的 main 已经包含 gitee/main 的全部提交于是正常把合并结果推回 Gitee。这样一来两边最终会收敛到一个共同的历史。如果对这种“creating merge commit”的方式有洁癖也可以改用git rebase gitee/main让 GitHub 侧提交放到 Gitee 提交之后但我不推荐原因是多人协作时 rebase 改写了提交哈希其他人本地会有同步困扰。为了一个 merge commit 的整洁度不值得。5. 边界情况处理分叉冲突、LFS 大文件与许可证问题5.1 双方同时提交导致分叉时的处理策略这是整个方案里最需要“人”参与的环节。假设我在 GitHub 上提交了commit A同时在 Gitee 上也有一个commit B两者都基于旧的commit X。正向同步脚本会检测到gitee/main不是HEAD的祖先停止反向同步脚本会进入合并分支大概率产生冲突退出并报错。这时候我的建议是在本地手动拉取 GitHub 和 Gitee 的最新提交。用git diff看清楚两边各自改了哪些文件。手动决定保留哪边的改动提交解决冲突后的版本。推送到 GitHub然后等正向同步把结果带到 Gitee。这套流程虽然有人工介入但好处是你永远不会“静默丢失代码”。如果完全依赖自动化合并一旦自动选择错了修复成本远高于人工解决冲突。另外我在两个 workflow 中故意设置的exit 1会造成 Actions 显示失败。这是有意为之失败通知会通过 GitHub 的邮件或 App 推送提醒我处理。比在日志里写一条“跳过”然后装作没事更有用。要让机器在无法决策的时候大声说话。5.2 LFS 文件、大仓库和 Gitee Pages 的扩展如果你的仓库用了 Git LFS 管理大文件那么直接同步到 Gitee 大概率会出问题。Gitee 对 LFS 的支持和 GitHub 不完全一致仓库里会出现指针文件而不是实际的大文件内容。这种情况下自动同步方案不能照搬建议要么把 LFS 文件从同步范围内排除要么不用 Gitee 作为同步目标只保留 GitHub 存储。大仓库的另一个问题是同步耗时。我的项目里有几千个 commitfetch起来还好但如果你仓库里有几百 MB 的二进制文件每次轮询都全量 fetch 会消耗大量 Actions 分钟数。GitHub Actions 的免费额度对个人项目通常够用但如果是私有仓库且每分钟都在跑 schedule建议把 cron 频率调低到每小时一次只在代码变更比较频繁的 push 事件上做实时同步。另外如果你打算在 Gitee 上用 Pages 展示项目这里的同步动作是和 Pages 构建解耦的。同步脚本只管让 Gitee 仓库保持最新Gitee Pages 的部署走 Gitee 自己的服务就好。需要提醒的是 Gitee Pages 目前要求实名认证而且构建比 GitHub Pages 慢一些别把“代码同步”和“页面部署”混在一起排查问题。5.3 许可证选择与同步仓库的合规细节有些朋友在 Gitee 上新建仓库时会纠结“开源许可证选什么”这本身是个好问题但它不影响 Git 层面的同步机制。MIT、GPL、Apache-2.0 都只是一份 LICENSE 文件同步时会作为普通文件一起带过去。真正需要注意的是如果你在 GitHub 上复用了别人的代码或者你的项目本身是 fork 来的那么一定要在同步到 Gitee 时保持 LICENSE 文件一致不要因为在 Gitee 上创建仓库时选了“无许可证”导致法律风险。我的经验是把 LICENSE 文件当作代码的一部分和 README 一样认真维护。同步时只要它是 repo 里的文件就不会遗漏。还要留意一个细节同步过去的分支名称尽量保持一致。GitHub 默认分支通常是mainGitee 新建仓库默认可能是master如果两边默认分支名不同一些网页端合并操作会产生意外的结果而且脚本里判断分支关系也会更绕。我建议在两边的仓库设置里都把默认分支调整为同一个名字。6. 维护半年下来的实用心得Gitee 和 GitHub 的双向同步跑了大半年GitHub 到 Gitee 的方向因为采用“推送 定时”组合触发基本上能做到改动后几分钟内同步Gitee 回 GitHub 的方向最坏情况有十分钟级延迟我也能接受。整体来看这个方案最核心的价值是它把“哪边更新、哪边落后、什么时候冲突”这些原本靠人猜的事变成了可判断、可告警、可审计的自动化流程。有几个容易被忽略的细节最后再强调一遍。一是 secrets 管理。GITEE_TOKEN一定要放在 GitHub 仓库的Settings → Secrets and variables → Actions里千万不要写进代码库的.env。Token 泄露比密码泄露更隐蔽因为它能直接代表你推代码。二是 schedule 的延迟。GitHub Actions 的定时任务并不保证分秒不差实际触发时间可能晚几分钟甚至在高负载时更晚。如果你对同步实时性要求很严就要依赖 push 事件触发而不是 cron。三是当我需要在本地手动处理分叉冲突时我会先把两边的 remotes 都 fetch 一遍再用一个独立的本地分支做实验确认合并结果没问题后才推上去。不要在 main 分支上直接乱试那会让 GitHub Gitee 方向再次检测到分叉触发失败通知干扰你的判断。如果你只是一个个人项目两边没有高频协作需求其实也可以只保留 GitHub → Gitee 的单向推送加一个轮询兜底但一旦你把仓库开放给别人提 PR双向机制的必要性就会很快显现出来。这套方案的好处在于随时可以在 workflow 的 schedule 配置里把频率降为零退化为纯事件触发也可以关掉其中一个 workflow回到单向模式。它是可裁剪的不会绑架你的工作流。