ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Python调用Gitea API实现全量仓库批量克隆与备份

Python调用Gitea API实现全量仓库批量克隆与备份 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/orgsStarred仓库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 仓库归属用户、组织和StarredGitea的仓库归属分三种用户仓库、组织仓库、以及当前用户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/repoclone_urlHTTPS clone地址ssh_urlSSH clone地址forks_countfork数量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, ftoken {GITEA_TOKEN}) req.add_header(Accept, application/json) with urllib.request.urlopen(req, timeout30) as resp: return json.loads(resp.read().decode(utf-8)) repos fetch(f/api/v1/users/{USERNAME}/repos?limit50page1) for repo in repos: print(repo[full_name], | mirror:, repo[mirror], |, repo[clone_url])这段代码跑通之后你已经拿到了第一批仓库。下一步就是正式插件里做组织和Starred的遍历了。3. 插件代码落地从列仓库到真正clone3.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_urlos.getenv(GITEA_URL, http://localhost:3000).rstrip(/), tokenos.getenv(GITEA_TOKEN, ), usernameos.getenv(GITEA_USERNAME, ), target_diros.getenv(GITEA_TARGET_DIR, ./gitea_repos), use_sshos.getenv(GITEA_USE_SSH, 0) 1, depthint(os.getenv(GITEA_CLONE_DEPTH, 0)), include_orgsos.getenv(GITEA_INCLUDE_ORGS, 1) 1, include_starredos.getenv(GITEA_INCLUDE_STARRED, 0) 1, skip_mirroros.getenv(GITEA_SKIP_MIRROR, 1) 1, retry_countint(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, ftoken {self.token}) req.add_header(Accept, application/json) with urllib.request.urlopen(req, timeout30) 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(limitlimit, pagepage) 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。我的建议是按场景区分一次性备份或下载用HTTPSToken可以写在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.extraHeaderAuthorization: token xxx的方式具体看你要不要在意这个细节。如果要写进自动化脚本、定时任务建议用extraHeader。浅克隆参数--depth很重要。我的经验是仓库数量多、单仓库历史深的情况下浅克隆能节省大量时间。但要注意备份场景浅克隆可能拿不全历史记录所以我默认参数GITEA_CLONE_DEPTH0也就是全量克隆。只有当你明确只是临时看看代码、不需要历史时再设置成--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_count3, delay5): for attempt in range(1, retry_count 1): try: proc subprocess.run( cmd, capture_outputTrue, textTrue, timeout1800, ) 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这里我设置timeout1800秒也就是单仓库最多跑半小时。如果某个仓库是大仓且有几千个提交半小时足够如果设定太短大仓库会被误杀。另外注意capture_outputTrue时如果仓库太大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字段是trueclone它们时有些镜像的.git目录结构和普通仓库不太一样更麻烦的是同步可能失败导致仓库处于半同步状态本地clone出来的文件不完整甚至乱码。我在第一版插件里踩过这个坑后来处理办法是默认跳过所有mirrortrue的仓库除非你明确要镜像内容。可以加一个参数--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。给本地仓库添加新的remotepush上去。关键点是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_URLhttp://git.example.com export GITEA_TOKENxxx export GITEA_USERNAMEci-user export GITEA_TARGET_DIR/data/code_cache python3 -m gitea_downloader download } }要注意的是CI环境里一般没有交互式shellclone失败重试没问题但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_outputTrue, textTrue ) 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这三个坑是最常见的提前处理能省很多事。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表