Gitee仓库批量备份:git clone --mirror与Python实现
2026/9/24 12:36:32 网站建设 项目流程

把代码备份这件事,我以前一直觉得很简单——把源码压缩包丢到网盘就完事了。直到有一次,我需要回滚一个三天前的功能,翻遍所有压缩包只找到最新的那份,中间那几版改动全都没了。那一刻我才意识到,代码备份真正的价值不在于那个 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 git@gitee.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, timeout=30) 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_ok=True) 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(f"echo \"[UPDATE] {name}\"") lines.append(f"git -C \"{target_dir}\" remote update --prune") else: lines.append(f"echo \"[CLONE] {name}\"") lines.append(f"git 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", encoding="utf-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", encoding="utf-8") as f: json.dump(summary, f, ensure_ascii=False, indent=2) 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 git@gitee.com:myaccount/my-project-new.git

git push --mirror会把镜像里的所有 refs 原样推到新仓库,包括所有分支和标签。这一步做完,新仓库在结构上和原仓库几乎一致,团队成员用普通 clone 拉下来就和以前一样工作。

5.2 还原前先做三层验证

我每次还原后都会做三个检查,确认备份真的没缺东西:

  • 提交数量对比:在镜像目录里执行git rev-list --all --count,记录总数;还原后的工作副本里执行git rev-list --all --count,数字应一致。
  • 分支和标签检查:git branch -agit tag应与备份信息中的分支列表吻合,尤其注意 release 分支是否完整。
  • 指定版本取出测试:用git log --oneline挑一两个历史 commit,直接git checkout <commit>试一下能不能正常切出代码。

这套验证看起来简单,但真的能救你。我之前有一次备份脚本因为网络中断,个别仓库实际没有 clone 完整,命令却显示成功,后来就是靠 commit 数量对比发现的。所以备份或还原之后,一定要有一次基于数据的验证,而不是看一眼"目录存在"就觉得没问题。

5.3 一次完整的灾难恢复演练

为了确认方案真能扛住事故,我自己做了两次完整的恢复演练,步骤大致如下:

  1. 删掉本地的开发目录,模拟本机代码丢失。
  2. 登录 Gitee,删掉一个测试仓库,模拟远端仓库也丢失。
  3. 从 gitee_backup 中找到对应仓库的 mirror 镜像。
  4. 基于镜像执行git clone,得到完整代码。
  5. 在 Gitee 上创建一个同名的空仓库。
  6. 在镜像目录执行git push --mirror把引用推送到新仓库。
  7. 在另一台新电脑上git clone新仓库地址,检查分支、标签、历史提交。

演练结果证明,只要备份镜像还在,即使本机和远端同时全没,也能把仓库完整请回来。这里要特别强调,演练不是浪费时间,它是备份方案里最容易被跳过但又最值得做的部分。没有演练过的备份方案,等于没验证过的假设。

6. 实际跑这个方案踩过的坑和优化建议

6.1 令牌千万别写死在脚本里

第一次做这个工具时,我图省事直接把 access_token 写在了 py 文件里。后来意识到,备份脚本本身如果被误提交到某个公开仓库,令牌立刻泄露,别人就能用你的身份读取或操作仓库。所以我改成从环境变量 GITEE_TOKEN 读取,并且建议在脚本里加一个简单的校验:令牌为空就直接退出,不继续执行。

另外,如果你用的是 Windows,设置临时环境变量可以这样:

set GITEE_TOKEN=你的令牌 python generate_gitee_backup_script.py

Linux/macOS 下则是:

export GITEE_TOKEN=你的令牌 python3 generate_gitee_backup_script.py

6.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 和本机同时出问题的极端场景下,也还有最后一根救命稻草。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询