全量倉庫批量克隆與備份)
Gitea用久了之后我一直缺一個趁手的Python插件一鍵把實例上所有倉庫的代碼全部拉下來。早先靠手動復制地址再git clone倉庫少還行等團隊倉庫破百組織、個人、鏡像倉庫混在一起這活兒就變成純體力活了。后來我花了點時間寫了個Python小工具對著Gitea API把倉庫列表、組織列表、Starred列表都翻了一遍然后自動拼接clone地址批量下載。這篇文章就是把這個插件的完整思路、實現(xiàn)代碼和實際踩坑記錄整理出來寫給同樣在維護Gitea、或者想把Gitea倉庫遷走備份的開發(fā)者參考。先說清楚這個工具解決什么問題它能自動遍歷Gitea實例上你有權限訪問的所有倉庫不管是用戶倉庫、組織倉庫還是你Starred的項目統(tǒng)一按目錄結構拉取到本地支持HTTPS Token認證和SSH兩種clone方式還能跳過鏡像倉庫、自動重試、斷點續(xù)傳。如果你需要定期備份代碼、遷移Gitea到新服務器、或者給代碼審計/靜態(tài)掃描準備一份全量源碼這個小插件可以直接抄作業(yè)。1. 為什么需要全量拉取Gitea實例維護者的核心痛點1.1 手動clone到崩潰的臨界點我第一次意識到必須自動化是一次服務器遷移。Gitea跑了快兩年上面有用戶個人項目、團隊內部庫、還有幾個從外部同步的鏡像庫加起來一百多個。當時覺得不就是clone一下嘛結果手動操作的時候發(fā)現(xiàn)要先在網頁上一頁一頁翻倉庫列表每頁50個然后看清楚哪個是組織倉庫、哪個是個人倉庫復制地址之前還得區(qū)分是走HTTPS還是SSH。折騰到一半網絡中斷個別倉庫clone到一半失敗我又得回去找是哪個倉庫沒成功。那一刻我就在想Gitea明明提供了完整的REST API為什么不用Python把所有倉庫信息拎出來一鍵批量clone后來我調研了一圈發(fā)現(xiàn)確實有不少人寫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返回什么字段開發(fā)體驗比對著文檔猜要友好太多。1.3 先劃清邊界這個插件不做什么寫工具之前最好先明確邊界不然會越搞越復雜。我的這個插件定位很清晰只做拉取源碼不做備份Gitea數(shù)據(jù)庫、不備份附件、不備份LFS大文件。Gitea本身有官方的備份命令gitea dump可以打包整個實例數(shù)據(jù)那是全量災備方案。但如果你只是想要一份能直接打開看代碼、能直接提交的Git倉庫集合gitea dump出來的zip反而不好直接使用這時候用API遍歷加git clone的方式最合適。另外這個插件也不處理Gitea升級、倉庫權限變更這類管理操作它只是只讀地讀取倉庫元數(shù)據(jù)然后調用本機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。排查了半天才發(fā)現(xiàn)是組織權限沒開。API調用時在請求頭里加Authorization: token {TOKEN}即可注意Gitea和GitHub不同它不要求Bearer前綴直接寫token就行。2.2 倉庫歸屬用戶、組織和StarredGitea的倉庫歸屬分三種用戶倉庫、組織倉庫、以及當前用戶Starred的倉庫。批量下載時大部分人的需求是把用戶自己和所屬組織的倉庫全拉下來Starred的倉庫看情況可能是網上其他人項目不一定要下載。我建議的處理方式是用戶倉庫和組織倉庫默認全量拉取Starred倉庫默認跳過可以用參數(shù)--include-starred開啟。因為Starred倉庫不是你擁有的代碼只是關注列表拉下來會占用空間。在遍歷組織時要注意一個用戶可能屬于多個組織每個組織又有自己的倉庫所以邏輯應該是先拉當前用戶信息拿到用戶名。拉用戶倉庫列表。調用/user/orgs拿到所有組織列表。遍歷每個組織拉組織倉庫列表。合并兩個列表按倉庫名去重。2.3 分頁、字段選擇與一個預檢腳本Gitea API默認分頁返回limit參數(shù)可以調但建議設為50或100。實際上Gitea API沒有強制限制limit的最大值但設太大會增加單次響應時間也容易超時。穩(wěn)妥做法是寫個while循環(huán)當返回列表長度小于limit時說明已經是最后一頁。倉庫列表返回的JSON字段很豐富對我們有用的主要有字段說明name倉庫名full_name完整名稱形如owner/repoclone_urlHTTPS clone地址ssh_urlSSH clone地址forks_countfork數(shù)量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內網環(huán)境往往沒有外網pip源裝requests有時候還得配代理麻煩。用Python標準庫urllib.request發(fā)請求用subprocess調git命令整個插件只依賴系統(tǒng)Git和Python 3.8可以說是零依賴。項目結構我分成四個文件后續(xù)擴展也方便gitea_downloader/ ├── __init__.py ├── api_client.py # Gitea API封裝 ├── clone_runner.py # git clone執(zhí)行與重試邏輯 ├── config.py # 配置讀取 └── main.py # 入口組裝完整流程如果你只是臨時用也可以把所有邏輯塞進一個腳本。但我建議保留模塊化結構因為后面你很可能想加只拉某個組織的倉庫、過濾某個前綴的倉庫、導出倉庫清單CSV這類功能模塊化改起來輕松很多。config.py里我使用環(huán)境變量讀取配置這樣不會把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返回的列表長度小于請求數(shù)量就停止。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)這里有個細節(jié)_get的返回可能是對象也可能是數(shù)組分頁場景默認是數(shù)組但get_user返回的是單個對象所以類型我做成了list是有點偷懶實際用的時候可以拆開或者加泛型。不過不影響主流程。合并倉庫清單的邏輯放在main.py里。注意去重full_name是唯一標識同一個倉庫不會同時出現(xiàn)在用戶倉庫和組織倉庫里但為了穩(wěn)妥還是加個集合判斷。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我在對接時發(fā)現(xiàn)路徑稍有區(qū)別Starred走的是用戶Starred接口/api/v1/user/starred組織走的是/api/v1/orgs/{org}/repos。這兩個接口在Gitea里都能正常返回JSON數(shù)組但字段完全一致所以下游代碼可以共用add邏輯。3.3 clone策略HTTPS、SSH與淺克隆的取舍倉庫清單到了本地之后最核心的問題是clone地址選HTTPS還是SSH。我的建議是按場景區(qū)分一次性備份或下載用HTTPSToken可以寫在URL里或者用git -c http.extraHeader傳認證信息。日常同步開發(fā)后續(xù)要推送修改用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的方式具體看你要不要在意這個細節(jié)。如果要寫進自動化腳本、定時任務建議用extraHeader。淺克隆參數(shù)--depth很重要。我的經驗是倉庫數(shù)量多、單倉庫歷史深的情況下淺克隆能節(jié)省大量時間。但要注意備份場景淺克隆可能拿不全歷史記錄所以我默認參數(shù)GITEA_CLONE_DEPTH0也就是全量克隆。只有當你明確只是臨時看看代碼、不需要歷史時再設置成--depth 1。3.4 執(zhí)行器失敗重試和日志別讓腳本靜默死掉clone執(zhí)行是整條鏈路中最容易出問題的環(huán)節(jié)網絡抖動、倉庫過大、權限不對都會導致clone失敗。clone_runner.py里我做了幾件事每個倉庫clone失敗后不中斷整個流程記錄錯誤繼續(xù)下一個。失敗重試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卻返回空列表。排查后發(fā)現(xiàn)是Gitea的Token權限模型里read:user和read:repository是分開的我只勾了read:user沒勾read:repository。這個問題好解決到Token設置頁面把read:repository也勾上即可。但我想提醒的是Gitea的Swagger頁面默認用的是當前登錄用戶的完整權限你在Swagger上測試成功不代表API Token也有同樣權限。調試時最好直接使用實際Token去訪問別用登錄態(tài)的Swagger來自我麻痹。4.2 鏡像倉庫clone之后一片亂碼Gitea支持把外部倉庫鏡像進來比如從GitHub同步一個開源項目。這些鏡像倉庫在API里的mirror字段是trueclone它們時有些鏡像的.git目錄結構和普通倉庫不太一樣更麻煩的是同步可能失敗導致倉庫處于半同步狀態(tài)本地clone出來的文件不完整甚至亂碼。我在第一版插件里踩過這個坑后來處理辦法是默認跳過所有mirrortrue的倉庫除非你明確要鏡像內容??梢约右粋€參數(shù)--include-mirror但默認不要開。另外鏡像倉庫的clone_url指向的是Gitea實例自身的地址如果鏡像同步失敗clone下來的東西很可能不是最新狀態(tài)用戶看到亂碼文件時會以為是插件寫壞了。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環(huán)境變量必須設置為外部可訪問的域名或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完成后不要修改任何倉庫設置保持原樣方便后續(xù)git pull增量更新。文件權限方面Gitea倉庫里如果有shell腳本clone下來后chmod可能丟失。如果你要直接使用這些腳本記得統(tǒng)一加執(zhí)行權限find . -name *.sh -exec chmod x {} \;這個不是插件功能但批量clone場景下很常見順手記一下。5. 把下載器用起來備份、遷移與CI聯(lián)動5.1 定時批量備份源碼到NAS既然能一鍵全量clone最直接的應用就是定期備份源碼。我的做法是寫一個crontab任務每周日凌晨3點執(zhí)行一次下載然后把目標目錄整體同步到NAS上的一個共享文件夾。增量更新的思路不要用普通的git clone而是后續(xù)用git pull --ff-only。但要注意如果你第一次是全量clone后續(xù)每次拉取可以用git -C {repo_dir} pull --ff-only。為了不把插件搞得太復雜我拆成了兩個模式download模式全量clone適合首次備份或重新初始化。update模式遍歷本地已有倉庫逐個git pull適合增量備份。update模式實現(xiàn)起來不復雜但要先判斷本地目錄是不是一個有效的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()然后對每個目錄執(zhí)行git -C {path} pull --ff-only如果pull失敗記錄下來不阻塞其他倉庫。5.2 從Gitea遷到GitLab時這個插件是搬運工熱搜詞里能看到大家經常對比GitLab和Gitea。如果你想把Gitea遷移到GitLab或者反過來官方工具多少有點限制但用這個插件配合GitLab API做鏡像就能實現(xiàn)手動遷移。流程是用插件把Gitea所有倉庫clone到本地。在GitLab那邊用API創(chuàng)建空項目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如果你的倉庫很多這個流程建議用腳本批量執(zhí)行別手敲命令容易漏。5.3 與Jenkins/Drone流水線配合Gitea生態(tài)里常見的是Gitea Drone或者Jenkins Gitea做CI/CD。有些流水線需要預先拉取所有倉庫到構建機尤其是做代碼掃描、統(tǒng)一構建的時候。這個下載插件可以變成CI的一個前置步驟構建機啟動時先執(zhí)行一次全量拉取然后各個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環(huán)境里一般沒有交互式shellclone失敗重試沒問題但Token千萬別打印到日志里。我在main.py里已經對Token做了脫敏處理日志只會輸出倉庫名和clone結果不會出現(xiàn)URL里的Token。5.4 代碼統(tǒng)計和倉庫健康檢查的延伸倉庫清單拉下來之后其實還能做很多事。比如分析哪些倉庫超過1GB、哪些倉庫最近一年沒有提交、哪些倉庫的分支數(shù)量異常多這些都能直接讀.git目錄統(tǒng)計。我之前就寫過一個簡單統(tǒng)計腳本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或直接推送到監(jiān)控系統(tǒng)你就有了Gitea代碼倉庫的全景視圖。這個是當初意外收獲現(xiàn)在也成了我維護Gitea的常規(guī)工具之一。就我個人經驗來說寫這個插件的最大收獲不是能批量clone而是理解了Gitea API的邊界和踩坑點。如果你也在維護Gitea建議先跑一遍預檢腳本確認API能正常返回再套用完整插件。token權限、鏡像倉庫、ROOT_URL這三個坑是最常見的提前處理能省很多事。