文章重點:
- Polyrepo(多儲存庫) 讓每個服務各有獨立倉庫。是傳統專案最直觀、最常見的架構
- Monorepo(單一儲存庫) 則是把多個服務與共用模組放進同一個 Git 倉庫的專案架構。
- 當「跨專案共用的功能」越來越多(例如 RBAC、檔案處理、共用工具),Polyrepo 的同步成本會指數上升,這通常是轉向 Monorepo 的分水嶺。
- Pants 是一套為 Monorepo 設計的建置系統,能跨子專案統一執行 lint / format / typecheck,並用「resolve」隔離各專案的相依版本。
- git-filter-repo + subtree merge 能在整併倉庫的同時保留每個原始 commit 與
git blame,不會把歷史壓成單一 commit。
一、前言
在軟體開發與團隊擴張的過程中,專案倉庫(Repository)的管理架構往往會走向一個關鍵的分水嶺。許多企業在推行微服務初期,為了開發方便,直覺採用了 Polyrepo(多儲存庫) 架構。
然而隨著服務與共用架構越來越多,微服務變得分散與碎片化,跨專案的管理與維護成本將會呈指數上升。
試想:當十個專案共用的 RBAC 管理功能突然要調整時,如果要逐一去每個專案修改、再去檢查內容是否對齊,那對維運來說簡直是地獄般的折磨。
因此,像 Google、Meta 等軟體大廠開始採用 Monorepo(單一儲存庫) 架構管理專案,將所有的程式碼、模組放進同一個倉庫,把分散的功能轉變成可重複引用的模組,避免在多個程式碼庫之間協調更新的麻煩。
本文將分享真實的整併經驗:把兩個各自演進半年、技術棧不同的 Django/DRF 後端整併成同一個 Monorepo。我會比較 Monorepo 與 Polyrepo 的利弊,引進新一代建置系統 Pants 來破解單一倉庫的效能與相依魔咒,最後實戰示範如何透過 git-filter-repo 進行 commit 遷移、保留歷史提交紀錄。
二、Monorepo 與傳統 Polyrepo 比較
什麼是 Polyrepo?
Polyrepo(多儲存庫) 指的是每個專案或微服務各自擁有獨立的生命週期、版本控制與程式碼倉庫。這是最常見、也最直覺的架構:新服務開一個新 repo,團隊各管各的,互不干擾。
優勢很明顯:倉庫小、權限好切、CI 跑得快、團隊邊界清楚。
但,問題出在「共用」。
當多個倉庫依賴同一段邏輯(例如登入授權、RBAC 權限模型、共用工具函式),通常只有兩種選擇,而兩種都不好受:
- 複製貼上
每個 repo 各留一份。短期最快,長期最痛。
同一個 bug 要修 N 次,且各副本會悄悄漂移(drift)到彼此不一致。 - 抽成套件
把共用碼發成內部套件(private package)。
乾淨,但每次改動都要「改套件 → 發版 → 各 repo 升版 → 測試」,
一個小修正就是一輪跨倉庫的版本協調。
而 Monorepo,正是為了解決這些共用問題而誕生的架構。
什麼是 Monorepo?
Monorepo(單一儲存庫) 指的是把多個專案、服務與共用模組放進同一個 Git 倉庫,以同一份歷史、同一套工具鏈管理。
要注意的是,它不等於把所有程式碼耦合在一起;好的 Monorepo 仍維持清楚的模組邊界,只是讓「共用」變成一次 commit 就能跨專案生效的事。
以本次整併的專案為例,倉庫結構大致如下:
ignsw_bk_monorepo/
├── pants.toml # 建置設定:跨子專案的 lint / format / typecheck
├── docker-compose-{local,indev,usertest,prod}.yml
├── libs/
│ └── common/ # 共用 lib:兩個子專案都以「路徑相依」引用
│ └── ignsw_common/ # exception / utils / manager / model / serializer ...
└── projects/
├── service_a/ # 子專案 A:Python 3.11 + Django 5.2,獨立 pyproject / Dockerfile
└── service_b/ # 子專案 B:Python 3.12 + Django 6.0,獨立 pyproject / Dockerfile
關鍵在於 libs/common:
以前要在兩個倉庫各維護一份的 RBAC、檔案處理、共用工具,現在收斂成一份來源(single source of truth),兩個子專案直接引用。
改一次,兩邊同時生效,也同時被同一套測試守住。
Monorepo vs Polyrepo 對照表
| 面向 | Polyrepo(多儲存庫) | Monorepo(單一儲存庫) |
|---|---|---|
| 共用程式碼 | 複製或發內部套件,易漂移 | 單一來源,一次改動跨專案生效 |
| 跨專案重構 | 需多倉庫協調、逐一升版 | 一個 PR 即可同步修改與測試 |
| 程式碼可見性 | 各倉庫互相看不到 | 全域可搜尋、可追溯 |
| 相依版本管理 | 天然隔離 | 需建置工具刻意隔離(見第二節的 pants) |
| 權限控管 | 以倉庫為單位,簡單 | 需目錄層級權限或 CODEOWNERS |
| CI/Build 成本 | 倉庫小、天然增量 | 倉庫大,需建置工具做增量與快取 |
| 上手門檻 | 低 | 需要工具與規範投資 |
什麼時候該從 Polyrepo 轉向 Monorepo?
Monorepo 不是銀彈。一個務實的判斷標準是:「跨專案共用的東西,是否多到讓同步本身變成主要成本?」
- 如果你的服務彼此獨立、幾乎不共用邏輯
→ 那 Polyrepo 就夠了。 - 如果你發現自己反覆在多個 repo 之間複製同一段修正、或維護一堆內部套件的版本矩陣
→ 那是該考慮一下 Monorepo。
以我們的實際經驗來說,像是 Redis、RBAC 等模組設計在兩個專案幾乎高度重疊,卻分散在兩個倉庫各自演進。整併進 Monorepo 後,這些共用碼收斂到 libs/common,但每個子專案依然能獨立部署、各自選擇 Django/Python 版本。
三、Monorepo 管理工具:Pants

為什麼 Monorepo 需要專門的建置工具?
Monorepo 最常被質疑的就是「效能」。
倉庫一大,全量 lint、全量測試、全量 build 都會變慢;而且不同子專案的相依套件版本可能互相衝突。建置系統(build system) 就是為了破解這兩件事而存在:
- 增量與快取:只對「真正受影響」的檔案做 lint / typecheck / test,而不是每次全跑。
- 相依隔離:讓同一個倉庫裡的子專案各自鎖定不同的套件版本,互不干擾。
Pants 是專為 Monorepo 設計的建置系統,原生支援 Python,並透過「依賴推斷(dependency inference)」自動分析 import 關係,決定哪些 target 受到影響。
實務經驗:Pants 只做 lint,不做測試
這裡是很重要的務實取捨。在這次整併中,Pants 只負責跨子專案統一執行程式碼風格檢查 ( lint / format / typecheck )。
./pants lint :: # 跨整個 Monorepo 跑 ruff(lint + format 檢查)
./pants fmt :: # 自動格式化
./pants check :: # mypy 型別檢查
Pants 當然可以做測試,但為什麼這樣抉擇?因為這兩個 Django 專案的設定(settings)仰賴 SECRET_KEY、資料庫連線等環境變數,由各環境的 .env 檔注入,難以在 Pants 的隔離沙盒裡忠實重現。強行讓測試在沙盒裡跑,反而會製造出與正式環境不一致的假陽性。認清工具的邊界、只用它最擅長的部分,往往比硬要 all-in 更穩。
用「resolve」隔離各專案的相依版本
Pants 用 resolve 的概念解決相依衝突——每個 resolve 對應一份獨立的 lockfile。本專案宣告了多個 resolve:
| Resolve | 對應 | 用途 |
|---|---|---|
service_a | 子專案 A 的 requirements.lock | Python 3.11 / Django 5.2 相依 |
service_b | 子專案 B 的 requirements.lock | Python 3.12 / Django 6.0 相依 |
ignsw_common | 共用 lib 的 requirements.lock | lib 自身 lint/typecheck 用 |
ruff | 工具專用 resolve | 鎖定 ruff 版本(見下方) |
共用 lib 的程式碼透過 parametrize 同時暴露給多個 resolve,子專案 import ignsw_common.* 時,Pants 的依賴推斷會自動鎖到「同一個 resolve」的變體。
讓兩個子專案共用同一份原始碼,卻各自綁定不同的 Django/Python 版本,正是 Monorepo 該有的樣子:共用邏輯,獨立相依。
四、保留你的專案紀錄:git-filter-repo
整併倉庫時,最容易被犧牲掉的就是 Git 歷史。如果只是把舊專案的檔案複製進新倉庫、再 commit 一次,那麼所有 git log、git blame 都會指向「整併當天的你」,原作者是誰、某行程式碼為什麼這樣寫、當初的 commit 訊息——全部消失。對需要追責、追問題根因的團隊來說,這是不可逆的損失。
Git 雖然有 subtree 指令,可以執行 git subtree add --squash 整併歷史提交,但這會把整段歷史壓縮成單一 commit。倉庫是整併了,但編輯紀錄 (blame) 就會全部消失。
雖然 Git 官方也有提供另一個歷史改寫工具 git filter-branch,但遇到大型 repo,它又慢又脆弱,容易出現許多 BUG。
因此,新的 git-filter-repo 工具如今已是官方推薦的新寵。它能把舊倉庫的完整歷史改寫成「彷彿一開始就活在新倉庫子目錄底下」的樣子,再併入 Monorepo。
實戰步驟
以我們的整合經驗來說,整體流程大致可分為:
- 在副本上改寫路徑
- merge 進 Monorepo:如果你是用 AI Agent 協助,最好請它將所有改寫都在
/tmp的 clone 副本上進行,並用 chmod 將原始倉庫讀寫權限改成唯讀,一個位元都不動。
# 1) 用 --no-local clone 出一份隔離副本(不會 hardlink 回原始 .git)
git clone --no-local ~/path/to/ServiceA /tmp/service-a-history
cd /tmp/service-a-history
# 2) 把原本位於 src/ 的程式碼提升到 repo 根
git filter-repo --path-rename src/:
# 3) 再把整段歷史改寫到目標子目錄底下
git filter-repo --to-subdirectory-filter projects/service_a
# 4) 回到 Monorepo,以「允許不相關歷史」的方式 merge 進來
cd /path/to/monorepo
git remote add tmp /tmp/service-a-history
git fetch tmp
git merge --allow-unrelated-histories tmp/main \
-m "Merge ServiceA history into projects/service_a"
git remote remove tmp
對第二個(以及未來每一個)子專案重複同樣兩步即可。本次整併就用這個流程,把兩個 repo 各自累積的完整 commit 歷史改寫後併入 Monorepo:
Merge ServiceA history into projects/service_a
Merge ServiceB history into projects/service_b
整併完成後,在 Monorepo 根目錄對任一被搬移的檔案執行 git log / git blame,理論上應該要能追到原始的作者、commit 訊息與日期:
git log -- projects/service_a/server/settings.py
# 看得到整併前每一筆原始 commit,而不是只有「整併」這一筆
幾個務實提醒
- 改寫後 SHA 會變:
filter-repo重寫歷史會改變 commit hash,這是已知的取捨。blame 仍指向原作者,但若需要對照原 repo,建議在 merge commit 訊息裡附上原始 HEAD SHA。 - 保護原始倉庫:動手前先
chmod -R a-w把來源 repo 設唯讀、再打一份 tar 快照,雙重保險。 - 走 dedicated branch:整段 merge 在專用分支上做、打一個
pre-history-mergetag;驗證沒問題再 fast-forward 回主線,不滿意就git reset --hard丟掉。 - tag 命名空間:來源 repo 的 tag 會被一起改寫;若不想污染 Monorepo 的 tag 命名空間,可用
--tag-rename為來源 tag 加 prefix(例如service_a/)。
五、總結
從 Polyrepo 走向 Monorepo,本質上是一個 「用前期的工具與規範投資,換取後期跨專案維護成本」 的決策。
最後總結一下這次整併的幾個關鍵心法:
- 共用碼多到同步變成主要成本時,就該考慮 Monorepo。 反覆複製修正、維護一堆內部套件版本矩陣,都是訊號。
- Monorepo ≠ 全部耦合。 好的 Monorepo 仍讓子專案獨立部署、獨立鎖相依版本——共用邏輯,但各自選擇 Django / Python 版本,各自匯出 image。
- 用建置工具破解效能與相依困境。 Pants 透過 resolve 隔離相依、依賴推斷做增量;但要認清工具邊界(本專案讓 Pants 只管 lint,測試交給 Docker Compose)。
- 工具版本要對齊。 Ruff 在 Pants 與編輯器間版本不一致,會製造假性紅綠燈——Monorepo 的一致性要從工具版本先做起。
- 整併歷史用 git-filter-repo,別用 squash。 在副本上改寫、保留每一筆 commit 與 blame,原始倉庫全程唯讀。
如果你的團隊正站在這個分水嶺上,不用急著一次到位。可以先把「最痛的共用模組」抽進一個共用 lib、用建置工具守住一致性、用 git-filter-repo 保住歷史。
Monorepo 目的是成為降低維護成本的槓桿,而不是另一個難以駕馭的大泥球。
六、常見問題(FAQ)
Q:Monorepo 和 Polyrepo 最大的差別是什麼?
A:Polyrepo 讓每個服務各有獨立倉庫,天然解耦但共用碼難同步;Monorepo 把多個服務與共用模組放進同一倉庫,共用碼一次改動即可跨專案生效,但需要建置工具來隔離相依與維持效能。
Q:採用 Monorepo 之後,子專案還能各自獨立部署嗎?
A:可以,而且應該。Monorepo 只代表「共用同一份原始碼與歷史」,不代表綁在一起發布。本文的專案讓每個子專案各有獨立的 pyproject.toml、Dockerfile 與相依鎖檔,各自匯出 image、各自部署。
Q:為什麼 Monorepo 需要 Pants 這類建置工具?
A:倉庫變大後,全量 lint / 測試 / build 會變慢,且不同子專案的套件版本可能衝突。Pants 提供增量建置、快取,以及用「resolve」隔離各子專案相依版本的能力。
Q:git-filter-repo 和 git subtree –squash 該選哪個?
A:若你想保留每一筆原始 commit 與 git blame,用 git-filter-repo(改寫歷史、保留作者)。git subtree --squash 會把整段歷史壓成單一 commit,blame 會遺失,只適合不在意歷史的情境。
Q:用 git-filter-repo 整併,會改到原本的舊倉庫嗎?
A:不會——前提是你照正確做法操作:先用 git clone --no-local 複製一份隔離副本,所有改寫都在副本上進行,原始倉庫設為唯讀並保留快照。
![]()