Gitea用久了之后,我一直缺一个趁手的Python插件:一键把实例上所有仓库的代码全部拉下来。早先靠手动复制地址再git clone,仓库少还行,等团队仓库破百,组织、个人、镜像仓库混在一起,这活儿就变成纯体力活了。后来我花了点时间写了个Python小工具,对着Gitea API把仓库列表、组织列表、Starred列表都翻了一遍,然后自动拼接clone地址批量下载。这篇文章就是把这个插件的完整思路、实现代码和实际踩坑记录整理出来,写给同样在维护Gitea、或者想把Gitea仓库迁走备份的开发者参考。
先说清楚这个工具解决什么问题:它能自动遍历Gitea实例上你有权限访问的所有仓库,不管是用户仓库、组织仓库还是你Starred的项目,统一按目录结构拉取到本地,支持HTTPS Token认证和SSH两种clone方式,还能跳过镜像仓库、自动重试、断点续传。如果你需要定期备份代码、迁移Gitea到新服务器、或者给代码审计/静态扫描准备一份全量源码,这个小插件可以直接抄作业。
1. 为什么需要"全量拉取":Gitea实例维护者的核心痛点
1.1 手动clone到崩溃的临界点
我第一次意识到必须自动化,是一次服务器迁移。Gitea跑了快两年,上面有用户个人项目、团队内部库、还有几个从外部同步的镜像库,加起来一百多个。当时觉得"不就是clone一下嘛",结果手动操作的时候发现:要先在网页上一页一页翻仓库列表,每页50个;然后看清楚哪个是组织仓库、哪个是个人仓库;复制地址之前还得区分是走HTTPS还是SSH。折腾到一半,网络中断,个别仓库clone到一半失败,我又得回去找是哪个仓库没成功。那一刻我就在想,Gitea明明提供了完整的REST API,为什么不用Python把所有仓库信息拎出来,一键批量clone?
后来我调研了一圈,发现确实有不少人写bash脚本做这件事,无非是curl调API再用xargs跑git。但bash处理JSON还依赖jq,而且逻辑复杂一点就难维护。我本身是Python技术栈,平时也用Python做自动化运维,干脆就自己写一个Python插件,把"列仓库、拼地址、调git clone、重试、跳过镜像"这些逻辑做成模块化工具。
1.2 Gitea API比GitLab简单在哪
如果你同时用过GitLab和Gitea的API,应该能感受到Gitea确实轻量。GitLab的权限模型很重,一个Project挂在Group下面还要考虑Subgroup嵌套,拉全量仓库得递归遍历Group。Gitea的结构简单直接:用户(User)、组织(Organization)、仓库(Repository)三层,而且API路径非常直观:
- 用户仓库:
GET /api/v1/users/{username}/repos - 组织仓库:
GET /api/v1/orgs/{org}/repos - 当前用户信息:
GET /api/v1/user - 当前用户所属组织:
GET /api/v1/user/orgs - Starred仓库:
GET /api/v1/user/starred
这意味着你不需要递归很多层,几个接口就能拿到全部仓库清单。另外Gitea自带Swagger调试页面,默认在/swagger路径,你可以在网页上直接试API返回什么字段,开发体验比对着文档猜要友好太多。
1.3 先划清边界:这个插件不做什么
写工具之前最好先明确边界,不然会越搞越复杂。我的这个插件定位很清晰:只做"拉取源码",不做备份Gitea数据库、不备份附件、不备份LFS大文件。Gitea本身有官方的备份命令gitea dump,可以打包整个实例数据,那是全量灾备方案。但如果你只是想要一份"能直接打开看代码、能直接提交的Git仓库集合",gitea dump出来的zip反而不好直接使用,这时候用API遍历加git clone的方式最合适。
另外,这个插件也不处理Gitea升级、仓库权限变更这类管理操作,它只是只读地读取仓库元数据,然后调用本机Git客户端完成clone。这样设计的好处是安全性高,token只需要只读权限,不会对Gitea实例做任何写操作,跑在运维机器上也很放心。边界弄清楚之后,代码写起来就快了。
2. 动手前的API功课:Gitea的仓库访问模型
2.1 认证方式与token权限最小集
Gitea支持Token认证,也支持Basic Auth,但实际操作中推荐用Token。在Gitea页面右上角点头像,进入设置 -> 应用 -> 生成新令牌,勾选权限时注意勾选read:repository、read:organization、read:user这几个就够了。Token相当于你的API钥匙,不要用管理员账号的Token,给普通运维账号开一个最小权限Token就行。
我之前有一次就是因为Token权限没勾全,调用/user/orgs接口能通,但拿到组织列表之后再调组织的repos接口就返回401。排查了半天才发现是组织权限没开。API调用时,在请求头里加Authorization: token {TOKEN}即可,注意Gitea和GitHub不同,它不要求Bearer前缀,直接写token就行。
2.2 仓库归属:用户、组织和Starred
Gitea的仓库归属分三种:用户仓库、组织仓库、以及当前用户Starred的仓库。批量下载时,大部分人的需求是把用户自己和所属组织的仓库全拉下来,Starred的仓库看情况,可能是网上其他人项目,不一定要下载。
我建议的处理方式是:用户仓库和组织仓库默认全量拉取;Starred仓库默认跳过,可以用参数--include-starred开启。因为Starred仓库不是你拥有的代码,只是关注列表,拉下来会占用空间。在遍历组织时要注意,一个用户可能属于多个组织,每个组织又有自己的仓库,所以逻辑应该是:
- 先拉当前用户信息,拿到用户名。
- 拉用户仓库列表。
- 调用
/user/orgs拿到所有组织列表。 - 遍历每个组织,拉组织仓库列表。
- 合并两个列表,按仓库名去重。
2.3 分页、字段选择与一个预检脚本
Gitea API默认分页返回,limit参数可以调,但建议设为50或100。实际上Gitea API没有强制限制limit的最大值,但设太大会增加单次响应时间,也容易超时。稳妥做法是写个while循环,当返回列表长度小于limit时说明已经是最后一页。
仓库列表返回的JSON字段很丰富,对我们有用的主要有:
| 字段 | 说明 |
|---|---|
name | 仓库名 |
full_name | 完整名称,形如owner/repo |
clone_url | HTTPS clone地址 |
ssh_url | SSH clone地址 |
forks_count | fork数量 |
mirror | 是否为镜像仓库 |
empty | 是否为空仓库 |
在写完整插件前,可以先写一个20行的Python预检脚本,把仓库列表打出来看看。我用urllib标准库就能完成,这样目标机器上不用额外装requests,也方便跑在干净的服务器上。
import json import os import urllib.request GITEA_URL = os.getenv("GITEA_URL", "http://localhost:3000") GITEA_TOKEN = os.getenv("GITEA_TOKEN", "") USERNAME = os.getenv("GITEA_USERNAME", "") def fetch(path): req = urllib.request.Request(GITEA_URL.rstrip("/") + path) req.add_header("Authorization", f"token {GITEA_TOKEN}") req.add_header("Accept", "application/json") with urllib.request.urlopen(req, timeout=30) as resp: return json.loads(resp.read().decode("utf-8")) repos = fetch(f"/api/v1/users/{USERNAME}/repos?limit=50&page=1") for repo in repos: print(repo["full_name"], "| mirror:", repo["mirror"], "|", repo["clone_url"])这段代码跑通之后,你已经拿到了第一批仓库。下一步就是正式插件里做组织和Starred的遍历了。
3. 插件代码落地:从列仓库到真正clone
3.1 项目结构:纯标准库也能跑
写这个插件的时候,我没有用第三方HTTP库,因为Gitea内网环境往往没有外网pip源,装requests有时候还得配代理,麻烦。用Python标准库urllib.request发请求,用subprocess调git命令,整个插件只依赖系统Git和Python 3.8+,可以说是零依赖。
项目结构我分成四个文件,后续扩展也方便:
gitea_downloader/ ├── __init__.py ├── api_client.py # Gitea API封装 ├── clone_runner.py # git clone执行与重试逻辑 ├── config.py # 配置读取 └── main.py # 入口,组装完整流程如果你只是临时用,也可以把所有逻辑塞进一个脚本。但我建议保留模块化结构,因为后面你很可能想加"只拉某个组织的仓库"、"过滤某个前缀的仓库"、"导出仓库清单CSV"这类功能,模块化改起来轻松很多。
config.py里我使用环境变量读取配置,这样不会把Token写死在代码里:
import os from dataclasses import dataclass @dataclass class Config: gitea_url: str token: str username: str target_dir: str use_ssh: bool = False depth: int = 0 include_orgs: bool = True include_starred: bool = False skip_mirror: bool = True retry_count: int = 3 @classmethod def from_env(cls): return cls( gitea_url=os.getenv("GITEA_URL", "http://localhost:3000").rstrip("/"), token=os.getenv("GITEA_TOKEN", ""), username=os.getenv("GITEA_USERNAME", ""), target_dir=os.getenv("GITEA_TARGET_DIR", "./gitea_repos"), use_ssh=os.getenv("GITEA_USE_SSH", "0") == "1", depth=int(os.getenv("GITEA_CLONE_DEPTH", "0")), include_orgs=os.getenv("GITEA_INCLUDE_ORGS", "1") == "1", include_starred=os.getenv("GITEA_INCLUDE_STARRED", "0") == "1", skip_mirror=os.getenv("GITEA_SKIP_MIRROR", "1") == "1", retry_count=int(os.getenv("GITEA_RETRY", "3")), )3.2 API Client:把三个来源的仓库合并成清单
api_client.py负责和Gitea API通信。我封装了几个方法:get_user_info、get_user_repos、get_orgs、get_org_repos、get_starred_repos。分页逻辑做成一个生成器,遇到limit返回的列表长度小于请求数量就停止。
import json import urllib.request from urllib.parse import urlencode class GiteaAPIClient: def __init__(self, base_url: str, token: str): self.base_url = base_url.rstrip("/") self.token = token def _get(self, path: str) -> list: req = urllib.request.Request(self.base_url + path) req.add_header("Authorization", f"token {self.token}") req.add_header("Accept", "application/json") with urllib.request.urlopen(req, timeout=30) as resp: return json.loads(resp.read().decode("utf-8")) def _paginate(self, path_template: str, limit: int = 50): page = 1 while True: path = path_template.format(limit=limit, page=page) items = self._get(path) yield from items if len(items) < limit: break page += 1 def get_user(self) -> dict: return self._get("/api/v1/user") def get_user_repos(self, username: str): return self._paginate(f"/api/v1/users/{username}/repos?limit={{limit}}&page={{page}}") def get_orgs(self): return self._get("/api/v1/user/orgs")这里有个细节:_get的返回可能是对象也可能是数组,分页场景默认是数组,但get_user返回的是单个对象,所以类型我做成了list是有点偷懒,实际用的时候可以拆开或者加泛型。不过不影响主流程。
合并仓库清单的逻辑放在main.py里。注意去重:full_name是唯一标识,同一个仓库不会同时出现在用户仓库和组织仓库里,但为了稳妥还是加个集合判断。
def collect_all_repos(cfg, api): seen = set() repos = [] def add(repo): if not repo.get("empty", False): key = repo["full_name"] if key not in seen: seen.add(key) repos.append(repo) user = api.get_user() username = user.get("login", cfg.username) if username: for repo in api.get_user_repos(username): add(repo) if cfg.include_orgs: for org in api.get_orgs(): org_name = org["username"] for repo in api.get_org_repos(org_name): add(repo) if cfg.include_starred: for repo in api.get_starred_repos(): add(repo) if cfg.skip_mirror: repos = [r for r in repos if not r.get("mirror", False)] return reposget_starred_repos和get_org_repos我在对接时发现路径稍有区别:Starred走的是用户Starred接口/api/v1/user/starred,组织走的是/api/v1/orgs/{org}/repos。这两个接口在Gitea里都能正常返回JSON数组,但字段完全一致,所以下游代码可以共用add逻辑。
3.3 clone策略:HTTPS、SSH与浅克隆的取舍
仓库清单到了本地之后,最核心的问题是clone地址选HTTPS还是SSH。我的建议是按场景区分:
- 一次性备份或下载:用HTTPS,Token可以写在URL里,或者用
git -c http.extraHeader传认证信息。 - 日常同步开发,后续要推送修改:用SSH,需要在Gitea里配置SSH公钥,clone下来之后
remote地址就是SSH,推送不用输密码。
代码里我保留了两个地址的切换:
def build_clone_url(repo: dict, use_ssh: bool) -> str: return repo.get("ssh_url") if use_ssh else repo.get("clone_url")HTTPS方式下,如果Gitea开了私有仓库,直接git clone会要求输入账号密码。为了避免交互,我在clone_runner.py里对HTTPS地址做了处理:如果是http(s)://开头,就把Token拼到URL里(只对fetch/push有效,避免Token泄露到磁盘remote配置里,所以用-c http.extraHeader更安全)。
def build_git_command(repo_dir: str, repo: dict, cfg: Config): if cfg.use_ssh: url = repo["ssh_url"] else: url = repo["clone_url"] if cfg.token and url.startswith("http"): from urllib.parse import urlparse, urlunparse parsed = urlparse(url) host_port = parsed.netloc url = f"{parsed.scheme}://{cfg.token}@{host_port}{parsed.path}" cmd = ["git", "clone"] if cfg.depth: cmd += ["--depth", str(cfg.depth)] cmd += [url, repo_dir] return cmd不过把Token拼在URL里的方式,Git会在clone成功后把远程地址写入.git/config并附带Token,这不太安全。我后来改成了用git -c http.extraHeader="Authorization: token xxx"的方式,具体看你要不要在意这个细节。如果要写进自动化脚本、定时任务,建议用extraHeader。
浅克隆参数--depth很重要。我的经验是:仓库数量多、单仓库历史深的情况下,浅克隆能节省大量时间。但要注意,备份场景浅克隆可能拿不全历史记录,所以我默认参数GITEA_CLONE_DEPTH=0,也就是全量克隆。只有当你明确只是临时看看代码、不需要历史时,再设置成--depth 1。
3.4 执行器:失败重试和日志,别让脚本静默死掉
clone执行是整条链路中最容易出问题的环节,网络抖动、仓库过大、权限不对,都会导致clone失败。clone_runner.py里我做了几件事:
- 每个仓库clone失败后不中断整个流程,记录错误继续下一个。
- 失败重试3次,每次间隔5秒。
- 输出简单日志:当前第几个仓库、仓库名、成功还是失败。
import subprocess import time def clone_repo_with_retry(cmd, repo_full_name, retry_count=3, delay=5): for attempt in range(1, retry_count + 1): try: proc = subprocess.run( cmd, capture_output=True, text=True, timeout=1800, ) if proc.returncode == 0: print(f"[OK] {repo_full_name}") return True else: print(f"[WARN] {repo_full_name} 第{attempt}次失败: {proc.stderr[-300:]}") except subprocess.TimeoutExpired: print(f"[WARN] {repo_full_name} 第{attempt}次超时") time.sleep(delay) print(f"[FAIL] {repo_full_name} 重试{retry_count}次仍失败") return False这里我设置timeout=1800秒,也就是单仓库最多跑半小时。如果某个仓库是大仓且有几千个提交,半小时足够;如果设定太短,大仓库会被误杀。另外注意capture_output=True时如果仓库太大,stderr可能积累很多输出,所以我只截取最后300个字符打到日志里,避免刷屏。
4. 实测踩坑记录:从clone失败到乱码的各种情况
4.1 Token权限不够,API能通但列表是空的
我在写这个插件的第2版时遇到过一个很隐蔽的问题:用管理员Token在Swagger页面上测试时,所有接口都正常。但换到普通运维账号时,/api/v1/user能返回用户信息,/api/v1/users/{username}/repos却返回空列表。排查后发现是Gitea的Token权限模型里,read:user和read:repository是分开的,我只勾了read:user,没勾read:repository。
这个问题好解决:到Token设置页面把read:repository也勾上即可。但我想提醒的是,Gitea的Swagger页面默认用的是当前登录用户的完整权限,你在Swagger上测试成功不代表API Token也有同样权限。调试时最好直接使用实际Token去访问,别用登录态的Swagger来自我麻痹。
4.2 镜像仓库clone之后一片乱码
Gitea支持把外部仓库镜像进来,比如从GitHub同步一个开源项目。这些镜像仓库在API里的mirror字段是true,clone它们时,有些镜像的.git目录结构和普通仓库不太一样,更麻烦的是同步可能失败,导致仓库处于半同步状态,本地clone出来的文件不完整甚至乱码。
我在第一版插件里踩过这个坑,后来处理办法是默认跳过所有mirror=true的仓库,除非你明确要镜像内容。可以加一个参数--include-mirror,但默认不要开。另外,镜像仓库的clone_url指向的是Gitea实例自身的地址,如果镜像同步失败,clone下来的东西很可能不是最新状态,用户看到乱码文件时会以为是插件写坏了。
4.3 Gitea在Docker容器里运行时,clone地址不对
我自己的Gitea是用Docker Compose部署的,映射宿主机端口和容器端口。Gitea默认对外地址如果配置的是http://localhost:3000,那么API返回的clone_url也是这个地址。在宿主机上跑克隆没问题,但如果从另一台机器跑插件,clone_url就是错的,git会尝试克隆localhost。
解决方法是配置Gitea的ROOT_URL为实际的对外访问地址,比如http://git.example.com。如果只是临时用,也可以在插件里对clone_url做字符串替换:
repo["clone_url"] = repo["clone_url"].replace( "http://localhost:3000", cfg.gitea_url )这条路子适合"先跑通再说"的情况,长期还是要把ROOT_URL配对。做容器化部署的朋友要留意:Gitea容器的GITEA__server__ROOT_URL环境变量必须设置为外部可访问的域名或IP,否则不只是这个插件,任何自动clone的工具都拿不到正确地址。
4.4 Windows路径和长路径问题
如果你在Windows上跑这个插件,subprocess.run调git clone时,要注意目标路径不能太长。Windows默认路径深度限制是260字符,仓库的嵌套目录加上仓库名很容易超。我在Windows测试时就遇到过Filename too long错误。
处理办法有两个:一是用Python 3.6+的os.path加上\\?\前缀;二是直接改用git clone的--separate-git-dir或者干脆把目标目录层级压平。实际操作中,我建议把保存目录结构压成owner_repo这种扁平命名,避免owner/repo两级目录叠加后路径过长。但扁平命名也有坏处,就是同一个owner下的仓库不能自然地按目录分组。看你的取舍了。我通常按owner/repo保存,在Linux服务器上跑没有任何问题,Windows就留给有特殊需求的场景。
4.5 Git换行符和文件权限问题
从Gitea clone下来的代码,如果仓库里有.gitattributes,换行符一般没问题。但有些Windows团队提交的代码带着CRLF,在Linux上clone下来后,IDE打开会提示。这不是插件需要解决的,但我建议clone完成后不要修改任何仓库设置,保持原样,方便后续git pull增量更新。
文件权限方面,Gitea仓库里如果有shell脚本,clone下来后chmod可能丢失。如果你要直接使用这些脚本,记得统一加执行权限:
find . -name "*.sh" -exec chmod +x {} \;这个不是插件功能,但批量clone场景下很常见,顺手记一下。
5. 把下载器用起来:备份、迁移与CI联动
5.1 定时批量备份源码到NAS
既然能一键全量clone,最直接的应用就是定期备份源码。我的做法是写一个crontab任务,每周日凌晨3点执行一次下载,然后把目标目录整体同步到NAS上的一个共享文件夹。
增量更新的思路不要用普通的git clone,而是后续用git pull --ff-only。但要注意,如果你第一次是全量clone,后续每次拉取可以用git -C {repo_dir} pull --ff-only。为了不把插件搞得太复杂,我拆成了两个模式:
download模式:全量clone,适合首次备份或重新初始化。update模式:遍历本地已有仓库,逐个git pull,适合增量备份。
update模式实现起来不复杂,但要先判断本地目录是不是一个有效的Git仓库。我的做法是检查{repo_dir}/.git或者{repo_dir}/.git文件是否存在(Gitea如果用的是worktree,有可能.git是一个文件)。
def is_git_repo(path: Path) -> bool: git_path = path / ".git" return git_path.exists()然后对每个目录执行git -C {path} pull --ff-only,如果pull失败,记录下来不阻塞其他仓库。
5.2 从Gitea迁到GitLab时,这个插件是搬运工
热搜词里能看到大家经常对比GitLab和Gitea。如果你想把Gitea迁移到GitLab,或者反过来,官方工具多少有点限制,但用这个插件配合GitLab API做镜像就能实现"手动迁移"。
流程是:
- 用插件把Gitea所有仓库clone到本地。
- 在GitLab那边用API创建空项目(Group + Project)。
- 给本地仓库添加新的remote,push上去。
关键点是push时要把所有分支、标签都推上去。普通git push origin --all能推分支,但标签要用git push origin --tags。如果仓库是SVN迁移过来的,可能还有奇怪的分支结构,建议push之后核对一下。
git -C {repo_dir} remote add gitlab git@{gitlab_host}:{group}/{project}.git git -C {repo_dir} push gitlab --all git -C {repo_dir} push gitlab --tags如果你的仓库很多,这个流程建议用脚本批量执行,别手敲命令,容易漏。
5.3 与Jenkins/Drone流水线配合
Gitea生态里常见的是Gitea + Drone或者Jenkins + Gitea做CI/CD。有些流水线需要预先拉取所有仓库到构建机,尤其是做代码扫描、统一构建的时候。这个下载插件可以变成CI的一个前置步骤:构建机启动时先执行一次全量拉取,然后各个Job从本地仓库目录直接取代码,避免每个Job都去Gitea上重复clone。
这种做法对网络和Gitea压力都很友好。我在实际使用中,把下载器做成一个Python CLI,然后被Jenkins的Pipeline调用:
stage("Fetch all repos from Gitea") { steps { sh ''' export GITEA_URL=http://git.example.com export GITEA_TOKEN=xxx export GITEA_USERNAME=ci-user export GITEA_TARGET_DIR=/data/code_cache python3 -m gitea_downloader download ''' } }要注意的是,CI环境里一般没有交互式shell,clone失败重试没问题,但Token千万别打印到日志里。我在main.py里已经对Token做了脱敏处理,日志只会输出仓库名和clone结果,不会出现URL里的Token。
5.4 代码统计和仓库健康检查的延伸
仓库清单拉下来之后,其实还能做很多事。比如分析哪些仓库超过1GB、哪些仓库最近一年没有提交、哪些仓库的分支数量异常多,这些都能直接读.git目录统计。我之前就写过一个简单统计脚本:
import subprocess from pathlib import Path base_dir = Path("/data/gitea_repos") for repo_dir in base_dir.iterdir(): if not (repo_dir / ".git").exists(): continue result = subprocess.run( ["git", "-C", str(repo_dir), "count-objects", "-vH"], capture_output=True, text=True ) size_line = [l for l in result.stdout.splitlines() if l.startswith("size-pack:")] if size_line: print(repo_dir.name, size_line[0])把这些信息输出成CSV或直接推送到监控系统,你就有了Gitea代码仓库的全景视图。这个是当初意外收获,现在也成了我维护Gitea的常规工具之一。
就我个人经验来说,写这个插件的最大收获不是"能批量clone",而是理解了Gitea API的边界和踩坑点。如果你也在维护Gitea,建议先跑一遍预检脚本确认API能正常返回,再套用完整插件。token权限、镜像仓库、ROOT_URL这三个坑是最常见的,提前处理能省很多事。