资讯详情

资讯详情

Gitee仓库批量备份:git clone --mirror与Python实现

把代码备份这件事我以前一直觉得很简单——把源码压缩包丢到网盘就完事了。直到有一次我需要回滚一个三天前的功能翻遍所有压缩包只找到最新的那份中间那几版改动全都没了。那一刻我才意识到代码备份真正的价值不在于那个 zip 文件而在于提交记录本身。后来我花了一个周末用 Python 写了一个针对 Gitee 的批量备份指令生成器底层调用git clone --mirror把账号下所有仓库连同完整提交历史、分支、标签一次拉下来。这套方案我用了快两年期间还真的完整恢复过两次今天把整个思路和源码分享出来。1. 备份之前先想清楚提交记录比源码更值钱1.1 只复制源码文件备份了个寂寞先说一个最常见的误区很多人备份代码就是压缩一个 src 目录或者把 release 包存起来。这种备份在文件意外丢失的场景下确实能救命但遇到下面这些情况就完全不够用了。三周前的一个版本还能跑但不知道改了什么现在新代码跑不起来了想对比一下差异——没有历史无法 diff。某个功能上周还好好的这周突然出 bug想知道是不是某次提交引入了问题——没有历史只能靠记忆排查。团队里有人误删了整个仓库需要恢复到一个特定分支的最新状态——如果压缩包是三天前的那这三天的工作就永久丢了。提交记录的作用有点像代码世界的行车记录仪。它不仅记录每个版本的快照还记录每次改动的理由、时间、作者以及这些改动之间如何串联成一条完整的时间轴。真正高质量的代码备份应该把这条时间轴完整保留下来而不只是某个时间点的照片。1.2 三种常见备份方式的对比做备份之前我先把市面上常见的几种方式拉出来对比了一下备份方式是否保留提交记录是否保留所有分支是否保留标签体积适合场景复制源码文件 / zip否否否小临时应急git clone 普通克隆是本地会生成远端追踪分支但非默认分支需要单独处理是中常用开发git clone --mirror 镜像克隆是是所有 refs 都完整保留是较大完整备份普通克隆和镜像克隆最大的差别在于普通克隆默认只把远端的 HEAD 分支完整 checkout 出来其他分支以 refs/remotes/origin/* 的形式存在虽然也能追溯但如果你之后把这份克隆当作备份仓库去 push 回新仓库分支结构会和原来不完全一致。镜像克隆则是把远端的所有 refs 原封不动地镜像到本地分支、标签、远端引用全部一一对应所以它可以当成一个精确的恢复源随时生成任何分支的完整副本。还有一点经常被忽略提交记录本身也是团队协作的资产凭证。项目交接时新人可以通过 git log 快速理清模块演进的脉络遇到安全问题时需要精准定位某次敏感改动是什么时候由谁引入的做版本发布时需要基于某个 tag 拉出 release 分支。这些场景全部依赖完整的提交历史。所以我的判断是备份方案的最低标准必须是任何时候都能从备份中恢复出一个和原仓库行为一致的 Git 仓库——不是恢复一堆文件而是恢复一个仓库。2. 备份指令的底层原理git clone --mirror 到底做了什么2.1 一条命令保住整个仓库我采用的备份指令核心就一条git clone --mirror 仓库克隆地址 备份目录如果你是在 Linux 或 macOS 上执行仓库地址用 SSH 格式更稳定如果在 Windows 上临时测试HTTPS 地址也能用。备份目录的名字我习惯直接用仓库名比如git clone --mirror gitgitee.com:myaccount/my-project.git ./my-project.git执行完之后my-project.git 这个目录里没有工作区文件只有 .git 内容也就是 Git 仓库的全部元数据、对象库、引用列表。它本质上是一个裸仓库bare repository但和手动执行git init --bare创建的裸仓库不同mirror 克隆会自动把所有远端 refs包括分支、标签、pull request 引用等复制到本地同名 refs 下。这也是镜像克隆适合备份的关键它不自带工作区体积更紧凑同时又保留了完整的对象库任何一次提交的对象都躺在里面。哪怕原仓库后来被清空只要这份镜像还在就能还原出任意历史版本。2.2 增量更新别每次都全量重来如果账号下仓库很多、有些仓库历史又长每次备份都全量 mirror clone 会非常慢。实际用下来更理想的方案是首次全量镜像之后增量拉取。首次执行git clone --mirror时Git 会自动给这个裸仓库配置好一个名为 origin 的远程地址。后续想更新备份内容只需要进入这个裸仓库目录执行git remote update --prune这条命令会从 origin 拉取所有远端引用并删除远端已经不存在的 stale refs。比如你在 Gitee 上删掉了一个实验分支执行 --prune 后备份仓库里对应的 refs 也会清掉避免引用无限膨胀。如果你基于这份备份做镜像发布请保留这里的 --prune 参数因为它保证了备份仓库与源仓库的一致性和整洁度。有些场景下你可能希望即便源仓库的分支被删了备份里也还能找回那条分支。那就要放弃 --prune并在裸仓库里开启 reflog。不过这会增加备份仓库的复杂度和存储占用。我自己的方案是备份镜像保持和源一致另外按周打一份 tar 归档两套并存既有实时性又有关键节点的留底。2.3 为什么不推荐 git bundle 作为唯一的备份方式有人会问git bundle create不是也能把整个仓库打成一个文件吗确实可以bundle 文件也包含完整对象和 refs迁移起来也很方便。但实际用下来有两个让我放弃它作为主方案的原因第一bundle 是一次性快照想做增量更新需要自己维护上一次备份后的新提交集合复杂度和出错概率都偏大。第二bundle 文件本身是单文件形态万一文件损坏整个备份就废了而 mirror 裸仓库是目录结构Git 对象库里如果个别对象出问题恢复工具处理起来也更灵活。不是说 bundle 不能碰而是它更适合发版本时打包带走或者离线转移仓库这类单发场景。而备份这种需要长期、反复执行的事情mirror remote update 的组合更省心。3. 让 Python 去发现仓库先把账号下的仓库清单拉出来3.1 用 API 代替手工收集仓库地址备份的第一步不是执行 git 命令而是先拿到到底要备份哪些仓库。如果你只有三五个仓库手动写死地址也无可厚非。但账号一多、或者在组织里帮团队维护多份备份时手工维护仓库清单非常痛苦新增的仓库总会忘了加进去。更合理的方式是让脚本直接调用 Gitee 的开放 API自动拉取当前账号下的全部仓库列表。Gitee 开放 API 提供了一系列接口其中获取授权用户仓库列表的端点是GET https://gitee.com/api/v5/user/repos这个接口需要带 access_token 参数。令牌在 Gitee 的设置页面里创建路径大致是设置 → 安全设置 → 私人令牌 → 生成新令牌。生成时建议只勾选必要的 scope比如读取仓库信息即可没必要给写入权限。令牌生成后只会完整显示一次记得立刻存到密码管理器里。3.2 分页拉取与字段选择这个接口默认按页返回数据常见参数是 per_page 和 page。per_page 单页最多 100 条仓库数量超过 100 就需要翻页。翻页逻辑很简单循环递增 page直到某次返回的列表为空。响应数据里每个仓库对象包含很多字段对备份而言最有用的是name仓库名用来生成备份目录名full_name形如 username/repo-name 的完整名字ssh_url / https_url克隆地址备份指令要用default_branch默认分支比如 master 或 main我在脚本里主要取 name 和 ssh_url。这里提醒一下如果仓库是私有的普通匿名接口拉不到请求时必须带自己的 access_token如果你加入了企业还可以考虑用企业仓库列表接口把组织下的仓库也纳入备份范围具体接口路径以官方文档为准。3.3 限流请求别打太猛Gitee 开放 API 对请求频率有约束个人令牌也不例外。为了防止脚本高频请求被临时限制我在每次分页请求之间加入了 0.5 到 1 秒的 sleep。如果你的账号仓库数量特别多或者网络条件不稳定可以把 sleep 提高到 2 秒并在失败时做简单的重试。这里有个通用原则凡是调用第三方 API 的脚本都要像对待公共资源一样克制间隔时间拉长一点宁可慢一点也不要挨限流。实际我跑下来几十个仓库的分页拉取只需要几秒钟这些 sleep 的开销完全可以接受。4. 完整脚本仓库列表到一键备份指令的生成器4.1 脚本整体结构我的 Python 脚本分三段获取仓库列表、生成备份指令文件、打印备份说明。运行方式很简单python generate_gitee_backup_script.py脚本会读取环境变量 GITEE_TOKEN避免把令牌硬编码在源码里。Windows 和 Linux 通用最终生成的备份指令文件会根据操作系统区分Windows 下生成 .bat其他平台生成 .sh。import json import os import time import urllib.request import urllib.parse import sys GITEE_API https://gitee.com/api/v5 BACKUP_ROOT os.path.abspath(./gitee_backup) SCRIPT_NAME backup_all (.bat if sys.platform.startswith(win) else .sh) COMMIT_MSG backup_info.json def fetch_all_repos(token): repos [] page 1 per_page 100 while True: params urllib.parse.urlencode({ access_token: token, per_page: per_page, page: page, }) url f{GITEE_API}/user/repos?{params} req urllib.request.Request(url, headers{User-Agent: backup-script}) try: with urllib.request.urlopen(req, timeout30) as resp: data json.loads(resp.read().decode(utf-8)) except Exception as e: print(f[WARN] 拉取第 {page} 页失败: {e}, 10 秒后重试) time.sleep(10) continue if not data: break repos.extend(data) page 1 time.sleep(0.5) return repos def generate_script(repos): os.makedirs(BACKUP_ROOT, exist_okTrue) lines [] summary [] if sys.platform.startswith(win): lines.append(echo off) lines.append(set ROOT%~dp0) else: lines.append(#!/bin/bash) lines.append(ROOT\$(cd \$(dirname \$0\)\ pwd)\) lines.append(cd \$ROOT\) for repo in repos: name repo.get(name) ssh_url repo.get(ssh_url) https_url repo.get(https_url) clone_url ssh_url or https_url target_dir os.path.join(BACKUP_ROOT, name .git) if os.path.isdir(target_dir): lines.append(fecho \[UPDATE] {name}\) lines.append(fgit -C \{target_dir}\ remote update --prune) else: lines.append(fecho \[CLONE] {name}\) lines.append(fgit clone --mirror \{clone_url}\ \{target_dir}\) summary.append({ name: name, url: clone_url, default_branch: repo.get(default_branch), path: target_dir, }) if sys.platform.startswith(win): lines.append(echo backup done) else: lines.append(chmod x backup_info.json 2/dev/null; echo backup done) script_path os.path.join(BACKUP_ROOT, SCRIPT_NAME) with open(script_path, w, encodingutf-8, newline\n) as f: f.write(\n.join(lines) \n) if not sys.platform.startswith(win): os.chmod(script_path, 0o755) info_path os.path.join(BACKUP_ROOT, COMMIT_MSG) with open(info_path, w, encodingutf-8) as f: json.dump(summary, f, ensure_asciiFalse, indent2) print(f备份指令已生成: {script_path}) print(f仓库清单已生成: {info_path}) print(f共 {len(repos)} 个仓库) def main(): token os.environ.get(GITEE_TOKEN, ).strip() if not token: print(请先设置环境变量 GITEE_TOKEN) sys.exit(1) repos fetch_all_repos(token) if not repos: print(未获取到任何仓库请检查令牌权限) sys.exit(1) generate_script(repos) if __name__ __main__: main()代码里有两个细节想专门说明一每个仓库我先判断备份目录是否已存在存在就走 remote update 增量更新不存在才走 mirror clone。这样第一次执行是全量之后都是增量命令的幂等性很好。二失败重试用的是 10 秒间隔保证 API 限流时脚本不会疯狂重试。依赖方面只用到了 Python 标准库 urllib不装任何第三方包也能跑降低了部署成本。4.2 为什么不直接让 Python 执行而是生成指令文件这个设计我斟酌过Python 完全可以 subprocess 调 git 直接执行备份为什么还要先生成一个 .sh/.bat 文件主要原因是备份动作本身要可审查、可留存。生成的脚本文件是完整复制了当时的仓库列表和克隆地址执行之前你可以先打开看看确认没有异常仓库混进来执行之后这个文件本身又是一份操作记录。万一将来要追溯某次备份到底基于哪些仓库、什么时刻的镜像翻文件比翻终端输出可靠得多。另一个附带好处是你可以在生成后手动编辑个别仓库的地址比如某个仓库迁到了新地址不用改脚本逻辑改一行命令就行。如果你希望定时自动备份把运行 generate 脚本和 backup 脚本的任务一起丢给 cron 或 Windows 计划任务即可两次运行之间可以留个小间隔。比如每周六凌晨 2 点先重新生成指令2 点 5 分执行备份这样就保证新增仓库一定会被收进去。4.3 生成后的目录长什么样跑完脚本后gitee_backup 目录的结构大致如此gitee_backup/ ├── backup_all.sh # 可执行的备份指令 ├── backup_info.json # 仓库清单与备份信息 ├── my-project.git/ # 仓库 A 的 mirror 镜像 ├── another-repo.git/ # 仓库 B 的 mirror 镜像 └── ...其中 backup_info.json 保存了每个仓库的克隆地址、默认分支和备份路径。这份 JSON 在还原阶段很有用它相当于备份的目录索引让你不用逐个打开仓库就知道各镜像对应的是谁的仓库。5. 关键一环从镜像仓库把代码完整还原出来5.1 最常用的还原姿势从镜像克隆出工作副本镜像是裸仓库不能直接在里面改代码所以还原的常规操作是基于镜像克隆出一个普通仓库作为工作副本。git clone /path/to/gitee_backup/my-project.git ./my-project-restored分支、标签、提交历史会全部带过来。你会发现远程仓库还保留着 origin但 origin 的地址是镜像目录的本地路径。想和 Gitee 上的新仓库对接时改一下 origin 或者直接 push 新的远程地址即可。如果原仓库彻底没了新建一个空仓库后把镜像里的引用推过去cd /path/to/gitee_backup/my-project.git git push --mirror gitgitee.com:myaccount/my-project-new.gitgit push --mirror会把镜像里的所有 refs 原样推到新仓库包括所有分支和标签。这一步做完新仓库在结构上和原仓库几乎一致团队成员用普通 clone 拉下来就和以前一样工作。5.2 还原前先做三层验证我每次还原后都会做三个检查确认备份真的没缺东西提交数量对比在镜像目录里执行git rev-list --all --count记录总数还原后的工作副本里执行git rev-list --all --count数字应一致。分支和标签检查git branch -a和git tag应与备份信息中的分支列表吻合尤其注意 release 分支是否完整。指定版本取出测试用git log --oneline挑一两个历史 commit直接git checkout commit试一下能不能正常切出代码。这套验证看起来简单但真的能救你。我之前有一次备份脚本因为网络中断个别仓库实际没有 clone 完整命令却显示成功后来就是靠 commit 数量对比发现的。所以备份或还原之后一定要有一次基于数据的验证而不是看一眼目录存在就觉得没问题。5.3 一次完整的灾难恢复演练为了确认方案真能扛住事故我自己做了两次完整的恢复演练步骤大致如下删掉本地的开发目录模拟本机代码丢失。登录 Gitee删掉一个测试仓库模拟远端仓库也丢失。从 gitee_backup 中找到对应仓库的 mirror 镜像。基于镜像执行git clone得到完整代码。在 Gitee 上创建一个同名的空仓库。在镜像目录执行git push --mirror把引用推送到新仓库。在另一台新电脑上git clone新仓库地址检查分支、标签、历史提交。演练结果证明只要备份镜像还在即使本机和远端同时全没也能把仓库完整请回来。这里要特别强调演练不是浪费时间它是备份方案里最容易被跳过但又最值得做的部分。没有演练过的备份方案等于没验证过的假设。6. 实际跑这个方案踩过的坑和优化建议6.1 令牌千万别写死在脚本里第一次做这个工具时我图省事直接把 access_token 写在了 py 文件里。后来意识到备份脚本本身如果被误提交到某个公开仓库令牌立刻泄露别人就能用你的身份读取或操作仓库。所以我改成从环境变量 GITEE_TOKEN 读取并且建议在脚本里加一个简单的校验令牌为空就直接退出不继续执行。另外如果你用的是 Windows设置临时环境变量可以这样set GITEE_TOKEN你的令牌 python generate_gitee_backup_script.pyLinux/macOS 下则是export GITEE_TOKEN你的令牌 python3 generate_gitee_backup_script.py6.2 SSH 和 HTTPS怎么选镜像克隆时我用的是 ssh_url核心原因是 SSH 方式在配置好密钥之后不需要每次输入账号密码更适合自动化和定时任务。前提是你已经为 Gitee 配置好了 SSH 公钥配置方法网上很成熟核心就是把本地生成的公钥贴到 Gitee 的 SSH 公钥设置里。如果你的运行环境不方便配 SSH退而求其次用 HTTPS 地址也能跑但要注意两点一是 HTTPS 克隆私有仓库通常需要验证身份自动化时需要处理好凭据二是 HTTPS 在大文件传输时的稳定性可能不如 SSH 顺滑。所以我的经验是备份机器最好提前配好 SSH 密钥一劳永逸。6.3 别让备份目录无限膨胀镜像备份有个天然好处是只保存对象和引用不包含工作区文件所以体积比较紧凑。但增量更新跑久了远端删除的分支、force push 后废弃的旧对象依然可能残留在镜像里仓库会慢慢变大。虽然 git gc 可以帮忙整理但备份仓库的定位是保真偶尔体积大一点可以接受。担心空间的话就定期 git gc同时保留一份 tar 归档作为最终兜底。磁盘空间也是同理。备份目录尽量放在独立硬盘或网络存储上并且做一个简单的空间监控低于阈值就告警别等磁盘满了才发现备份全部失败。这种基础设施建设花的功夫不多但在关键时刻非常必要。6.4 大仓库备份失败后的处理历史很长的仓库首次 mirror clone 可能要跑很久中途断网也是常事。如果 clone 中断备份目录残留到一半的 .git 文件夹直接重新执行 mirror clone 会报 destination path already exists。我遇到这种情况的处理方式是把不完整的目录删掉重新克隆。如果你的网络实在不稳定可以换一个网络时段重试或者先git clone一个浅克隆再用git fetch --unshallow补全但这套操作对 mirror 仓库不太适用所以更推荐的做法是给首次全量备份安排一个固定窗口慢就慢一次跑完后面增量就轻松了。这套方案运行了差不多两年我最大的一个体会是备份工具的价值不是靠功能多寡决定而是靠在恢复的那一刻是否真的靠得住决定。与其等到出了问题才手忙脚乱不如现在就花一个下午把脚本搭好再做一次完整的还原演练。最后分享一个我个人的小习惯每次重大版本发布之后除了自动增量备份我会手动把对应的 tag 场景打一份 tar 归档放到另一个存储设备上。这样的双保险让我在 Gitee 和本机同时出问题的极端场景下也还有最后一根救命稻草。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →