Git Subtree:多專案整合的實用技巧
Git Subtree 介紹
什麼是 Git Subtree?
git subtree 是 Git 內建的專案管理工具,它解決了一個常見的開發需求:
如何在專案 A 中整合專案 B 的程式碼,同時保持專案 B 的獨立性與可更新性?
假設你正在開發一個主專案,需要使用另一個獨立維護的函式庫。如果只是把那個函式庫 clone 下來後複製到主專案中,雖然可以使用,但會遇到幾個問題:無法追蹤程式碼來源、難以同步原始專案的更新、無法將改進推送回原始專案。類似的情境還包括:
- 如何將主專案中某個模組拆分成獨立的 repository 供其他專案使用?
- 如何將多個獨立的小專案整合到一個統一的大 repository 中?
Git Subtree 提供的功能可以很好地解決這些問題。它可以將一個外部 Git repository 的內容完整嵌入到你的專案的子目錄中。這些內容會真正成為你的專案的一部分(不是連結或參照),同時保留與原始專案的關聯。使用 Git Subtree 後,你的專案結構可能如下:
main-project/
├── .git/
├── src/
│ └── main.js
├── lib/
│ └── shared-library/ ← Git Subtree (來自外部 repo)
│ ├── index.js
│ └── utils.js
└── README.md
在這個例子中,lib/shared-library/ 目錄的內容來自另一個獨立的 Git repository(例如 https://github.com/user/shared-library.git),但它已經完全整合到 main-project 中。
Git Subtree 有以下幾個主要的功能特點:
- 完整整合:當其他開發者 clone 主專案時,會直接取得
lib/shared-library/的所有檔案和內容,不需要執行任何額外的初始化指令。這與 Git Submodule 不同,後者 clone 後只會得到一個空目錄的參照,需要額外執行git submodule init和git submodule update才能取得實際內容。 - 可直接修改:
lib/shared-library/是真實存在於主專案中的目錄和檔案,不是符號連結或參照。你可以直接在主專案中編輯這些檔案,所有修改都會被 Git 正常追蹤。這與 Git Submodule 不同,後者的子目錄實際上是一個獨立的 Git repository,修改時需要進入該目錄並在其 Git 環境中操作。 - 雙向同步:可以從原始專案拉取更新(使用
git subtree pull將原始shared-libraryrepository 的新版本同步到主專案),也可以將主專案中的修改推送回原始專案(使用git subtree push將你在主專案中對lib/shared-library/的改進推送回原始的shared-libraryrepository)。
Git Subtree vs Git Submodule
| 特性 | Git Subtree | Git Submodule |
|---|---|---|
| 內容儲存 | 完整複製到主 repo | 僅儲存 commit 參照 |
| Clone 行為 | 一次 git clone 即可取得所有內容 | 需要額外執行 git submodule init/update |
| 學習曲線 | 較簡單,使用標準 Git 指令 | 較複雜,需要理解 submodule 概念 |
| 歷史記錄 | 子專案歷史可選擇性保留或壓縮 | 子專案歷史獨立於主專案 |
| 檔案大小 | 主 repo 較大(包含所有內容) | 主 repo 較小(僅參照) |
| 適用情境 | 需要完整整合、簡化協作流程 | 需要明確版本控制、多專案共用 |
| 修改子專案 | 可直接在主 repo 中修改並推回 | 需要進入 submodule 目錄操作 |
Git Subtree 的兩大常見用法
1. 將外部專案整合到主專案(Add & Pull)
使用情境:在主專案中使用某個函式庫或共用模組,並且能夠定期同步上游的更新。
2. 從主專案拆分出子專案(Split & Push)
使用情境:將主專案中開發某個模組拆分成獨立的 repository,方便其他專案使用。
Git Subtree 指令詳解
git subtree add
用途:將外部 repository 的內容加入到當前 repo 的指定子目錄中。
語法
git subtree add --prefix=<dir> <repository> <ref> [--squash] [--message=<msg>]
參數說明
| 參數 | 必填/選填 | 說明 | 預設值 |
|---|---|---|---|
--prefix=<dir> | 必填 | 指定子專案要放置的目錄路徑 | 無 |
<repository> | 必填 | 遠端 repo 的 URL 或已設定的 remote 名稱 | 無 |
<ref> | 必填 | 要拉取的分支名稱、tag 或 commit hash | 無 |
--squash | 選填 | 將外部 repo 的所有歷史壓縮成單一 commit | 不壓縮,保留完整歷史 |
--message=<msg> | 選填 | 自訂合併 commit 的訊息 | 自動生成訊息 |
實際範例
# 範例 1:加入外部函式庫(保留完整歷史)
git subtree add --prefix=lib/utils https://github.com/user/utils-lib.git main
# 範例 2:加入外部函式庫(壓縮歷史,保持主專案乾淨)
git subtree add --prefix=vendor/logger https://github.com/user/logger.git v1.0 --squash
# 範例 3:使用已設定的 remote
git remote add utils-remote https://github.com/user/utils-lib.git
git subtree add --prefix=lib/utils utils-remote main --squash
git subtree pull
用途:從遠端 repository 拉取最新變更,並合併到當前 repo 的 subtree 目錄中。用於同步上游專案的更新。
語法
git subtree pull --prefix=<dir> <repository> <ref> [--squash] [--message=<msg>]
參數說明
| 參數 | 必填/選填 | 說明 | 預設值 |
|---|---|---|---|
--prefix=<dir> | 必填 | 指定要更新的 subtree 目錄路徑 | 無 |
<repository> | 必填 | 遠端 repo 的 URL 或 remote 名稱 | 無 |
<ref> | 必填 | 要拉取的分支、tag 或 commit | 無 |
--squash | 選填 | 將更新壓縮成單一 commit | 不壓縮,保留完整歷史 |
--message=<msg> | 選填 | 自訂合併 commit 訊息 | 自動生成訊息 |
實際範例
# 範例 1:更新 subtree(保留完整歷史)
git subtree pull --prefix=lib/utils https://github.com/user/utils-lib.git main
# 範例 2:更新 subtree(壓縮歷史)
git subtree pull --prefix=lib/utils https://github.com/user/utils-lib.git main --squash
# 範例 3:使用 remote 名稱
git subtree pull --prefix=vendor/logger logger-remote v2.0 --squash
如果在 add 時使用了 --squash,那麼後續的 pull 也應該使用 --squash,以保持一致性。
當使用 --squash 時,Git 會壓縮外部 repo 的歷史並記錄特殊的合併點。如果後續 pull 不使用 --squash,Git 會嘗試合併完整歷史,但無法正確追踪合併基底(merge base),導致重複的 commit、合併衝突或歷史混亂。
git subtree push
用途:將 subtree 目錄中的變更推送回對應的外部 repository。適用於在主專案中修改了 subtree 的內容,需要同步回上游專案的情境。
語法
git subtree push --prefix=<dir> <repository> <ref>
參數說明
| 參數 | 必填/選填 | 說明 | 預設值 |
|---|---|---|---|
--prefix=<dir> | 必填 | 指定要推送 的 subtree 目錄路徑 | 無 |
<repository> | 必填 | 遠端 repo 的 URL 或 remote 名稱 | 無 |
<ref> | 必填 | 要推送到的目標分支名稱 | 無 |
實際範例
# 範例 1:推送變更到上游 main 分支
git subtree push --prefix=lib/utils https://github.com/user/utils-lib.git main
# 範例 2:推送到特定分支
git subtree push --prefix=vendor/logger logger-remote feature/new-feature
# 範例 3:使用 remote 名稱
git remote add utils-upstream https://github.com/user/utils-lib.git
git subtree push --prefix=lib/utils utils-upstream main
工作流程示意
1. 在主專案修改 subtree 內容
main-project/lib/utils/index.js (修改檔案)
↓
2. 在主專案 commit 變更
git add lib/utils/
git commit -m "fix: improve utils"
↓
3. 推送變更回上游
git subtree push --prefix=lib/utils https://github.com/user/utils-lib.git main
↓
4. 上游 repo 收到更新
utils-lib repo 的 main 分支更新
git subtree merge
用途:將本地已存在的分支或 commit 合併到 subtree 目錄中。與 pull 的差異在於 merge 不會自動從遠端抓取,僅處理本地已有的內容。
語法
git subtree merge --prefix=<dir> <ref> [--squash] [--message=<msg>]
參數說明
| 參數 | 必填/選填 | 說明 | 預設值 |
|---|---|---|---|
--prefix=<dir> | 必填 | 指定 subtree 目錄路徑 | 無 |
<ref> | 必填 | 要合併的本地分支名稱或 commit hash | 無 |
--squash | 選填 | 將合併壓縮成單一 commit | 不壓縮 |
--message=<msg> | 選填 | 自訂合併 commit 訊息 | 自動生成訊息 |
實際範例
# 範例 1:先 fetch 再 merge(分兩步驟)
git fetch https://github.com/user/utils-lib.git main
git subtree merge --prefix=lib/utils FETCH_HEAD --squash
# 範例 2:合併本地分支
git subtree merge --prefix=lib/utils utils-local-branch
# 範例 3:合併特定 commit
git subtree merge --prefix=lib/utils abc123def --squash
pull vs merge 的差異
git subtree pull = git fetch + git subtree merge
使用 pull(一步完成):
git subtree pull --prefix=lib/utils https://github.com/user/utils-lib.git main
↓
自動執行:fetch + merge
使用 fetch + merge(分兩步):
git fetch https://github.com/user/utils-lib.git main
git subtree merge --prefix=lib/utils FETCH_HEAD
git subtree split
用途:將主專案中某個子目錄的 Git 歷史切分出來,建立成一個獨立的分支。這個分支只包含該目錄的變更歷史,常用於從單一大型 repo 拆分出子專案。
語法
git subtree split --prefix=<dir> [--branch <name>] [--annotate=<str>] [--onto=<rev>] [--rejoin] [--ignore-joins] [--squash]
參數說明
| 參數 | 必填/選填 | 說明 | 預設值 |
|---|---|---|---|
--prefix=<dir> | 必填 | 指定要切分的子目錄路徑 | 無 |
--branch <name> | 選填 | 將切分結果直接輸出到指定分支 | 輸出 commit hash |
--annotate=<str> | 選填 | 在 commit message 加上前綴標註 | 無標註 |
--onto=<rev> | 選填 | 將切分後的歷史基於指定的 commit | 無基底 |
--rejoin | 選填 | 建立一個 join commit 記錄切分點 | 不建立 |
--ignore-joins | 選填 | 忽略先前的 join commit | 不忽略 |
--squash | 選填 | 將所有歷史壓縮成單一 commit | 保留完整歷史 |
實際範例
# 範例 1:切分子目錄並建立新分支
git subtree split --prefix=modules/auth --branch auth-module
# 範例 2:切分並推送到新的 repo
git subtree split --prefix=lib/utils --branch utils-split
git push https://github.com/user/new-utils-repo.git utils-split:main
# 範例 3:壓縮歷史後切分
git subtree split --prefix=components/ui --branch ui-lib --squash
# 範例 4:切分並加上註解
git subtree split --prefix=services/api --branch api-service --annotate="[API] "
使用 --rejoin 的好處
# 使用 --rejoin
git subtree split --prefix=modules/auth --branch auth-module --rejoin
--rejoin會建立一個特殊的 merge commit,記錄這次切分的位置。好處是:- 下次再執行
split時,Git 知道從哪裡開始,速度更快 - 避免重複處理已經切分過的歷史
- 適合需要多次執行
split的情境
我的使用情境:使用 Git Subtree 建立練習用專案管理 Repo
問題與需求
在學習新技術或框架時,我通常會建立小型實作專案來練習。每學一個新技術就會有一個新專案,數量累積起來相當可觀。由於是練習性質,這些專案的 commit 訊息和程式碼結構都比較隨意,因此我也不太想要把每個練習用的小專案都公開到 Github 上,怕會讓我的 Github 公開版面變得很雜亂。但另一方面,儘管這些專案雖然是練習,仍然有一定的展示價值,我還是希望能保留這些練習成果。
總合上面所述,整理了一下我的問題與需求:
- 每個小專案都公開 → GitHub 頁面變得很亂
- 全部設為私有 → 失去展示學習成果的機會
- 練習專案的 commit 通常很雜亂(如 "test", "fix bug", "try again")
- 不想讓這些不專業的 commit 訊息公開展示
- 練習專案可能會持續改進
- 需要一個簡單的方式同步更新到展示 repo