在開發過程中,專案隨著時間變得越來越肥,不時還生出子專案,此時就會遇到需要各專案共用一些 Code 的部分,如果共用的部分是用 複製貼上 的方式去同步,那勢必一定會造成兩邊不同步,維護困難。

本篇文將分享 Git Submodule 與 Git Subtree 的差異及它們的使用方法。

以下將以 SubRepo 代表要被匯入的 Repository,而 SuperRepo 則是把 SubRepo 匯入的 Repository。

Submodule vs Subtree

Submodule 與 Subtree 兩個都是可以將 SubRepo 加入 SuperRepo 的解決方法,但怎麼解決的及實際使用上都有所差異。

簡單來說 Submodule 是用像指標的方式,將 SubRepo 的 HASH 紀錄在 SuperRepo 中,而 Subtree 則是以副本的方式把 SubRepo 某版複製一份到 SuperRepo。

用表格看可能就更清楚了:

Submodule SubTree
Cost 僅佔用 .gitmodule 佔用等同 SubRepo 的大小的空間
Clone SuperRepo 需多步驟 原指令
Push to SubRepo 容易,視為兩個獨立的 Repo,可以直接 push 不容易,因為不知道 SubRepo log,還要比對
pull to SubRepo 不容易,需執行另外執行指令 容易,因為就只是 Pull SuperRepo
簡單形容 一個 Repo 中的另一個 Repo 跟原本 Repo 合併,視為一個子目錄

另外也可以用一句話的方式描述:

  • Submodule: 較易 push,較不易 pull,不佔空間,因為它只紀錄 HASH。
  • Subtree: 較不易 push,較易 pull,不佔空間,因為是副本。

Git Submodule 簡介

Submodule 最早出現在 v1.5.3 (可能吧?),根據 Release Note 中描述,出現的目的就是用於管理 super-project 中的子專案,可能因為它比較老,網路上的文件相對就比較多了。

它會將指定的 SubRepo 版本 Clone 到指定的路徑,並將這個版本的 HASH 紀錄在 SuperRepo 中。它有自己的 .git 目錄有自己的操作範圍,只要進到這個路徑就等於是進到另一個 Repo 了,外面只會記得目前在哪個版本。


Git Subtree 簡介

Subtree 出現的時間我就更不確定了,在 v1.5.2v1.7.11 都有出現,且好像一開始目的是用於合併專案的 (?

它一樣是將指定的 SubRepo 版本 Clone 到指定目錄,但差別在它會送一個 merge commit 到 SuperRepo,實際上看起來無故冒出一條線跟 SuperRepo 合併,然後它們就合而為一了。

大概就是像這樣:

graph LR subgraph SubRepo A1[abd7417] --> B1 B1[c690741] --> C1 C1[ff35922] --> D end subgraph SuperRepo A[7542f2f] --> B B[9db0cd9] --> C C[506df19] --> D D[Merge] --> F F[5d43e32] --> E E[d6cbc13] end

如何使用 Git Submodule ?

章節目錄

建立 Submodule

要將一個 SubRepo 使用 Submodule 的方式加到 SuperRepo,流程清單如下:

  1. git submodule add <repo url> <folder>
  2. git commit
  3. git push

接下來我們看實際執行畫面:

首先先執行 git submodule add 去加入 SubRepo

$ git submodule add git@github.com:puckwang/SubRepo.git sub-repo
Cloning into '~/sub-repo'...
remote: Enumerating objects: 3, done.
remote: Counting objects: 100% (3/3), done.
remote: Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
Receiving objects: 100% (3/3), done.

接下來執行 git status 就可以看到有新增了兩個未提交的檔案

$ git status
On branch master
Your branch is up to date with 'origin/master'.

Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

	new file:   .gitmodules
	new file:   sub-repo
  • .gitmodules 是用來記錄 Module 的路徑以及 Repo url。
  • sub-repo 放 Module 的目錄,雖然會有這個,但其實不會實際將整個 SubRepo 提交出去。

最後記得要 commitpush,另外要注意的是,因為 SuperRepo pull 是不會更新 Submodule,要自己手動更新

git commit
git push

Pull SuperRepo

如果 SuperRepo 所記錄的 Submodule 版本有更新,執行 pull 時是不會自動也去把它更新,且執行 status 時會看到提示

$ git status
On branch master
Your branch is up to date with 'origin/master'.

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

	modified:   sub-repo (new commits)

此時就要執行以下指令將 Submodule 更新,執行完再看 status 就不會有提示了。

git submodule update --recursive

Pull Submodule (更新 Submodule 版本)

如果 Submodule 有新的 Commit,在 SuperRepo 執行 pull 是不會更新的,要自己手動執行指令:

$ git submodule update --recursive --remote

remote: Enumerating objects: 4, done.
remote: Counting objects: 100% (4/4), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
Unpacking objects: 100% (3/3), done.
From github.com:puckwang/SubRepo
   1a39dc5..c9c4974  master     -> origin/master
Submodule path 'sub-repo': checked out 'c9c4974e887f2362cc9f7f9d4c90b19891969d67'
  • --recursive: 代表如果遞迴執行,Submodule 中如果有 Submodule,有一併更新。
  • --remote: 使用遠端的分支來更新,而不是 SuperRepo 所記錄的 HASH (更新 Submodule 版本用)。

或者是直接到 Submodule 裡面執行 pull 也可以。

執行完後就會發現 SuperRepo 中的 Submodule 目錄會被標示 (new commits),所以要記得 push。

$ git status
On branch master
Your branch is up to date with 'origin/master'.

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

	modified:   sub-repo (new commits)

no changes added to commit (use "git add" and/or "git commit -a")

Push Submodule

只要把 Submodule 當作一般 Repo 操作就可以了,但當 Submodule 有新的 commit 時,SuperRepo 所記錄的 HASH 都會改變,所以如果順序錯了話,會多一次 commit

比較好的順序如下:

  1. 在 Submodule commit 變更。
  2. 在 Submodule push 剛剛的 commit。
  3. 在 SuperRepo commit 變更 (這裡會包含新的 Submodule HASH)。
  4. 在 SuperRepo push 或是使用 git push --recurse-submodules=on-demand 就可以省略步驟 2。

刪除 Submodule

首先用以下指令將 submodule 紀錄刪除

git submodule deinit -f <submodule folder>

接下來刪除 submodule 的 .git,他會在 SuperRepo 的 .git 中的 modules 中。

rm -rf .git/modules/path/to/submodule

最後一步是刪除實體的目錄

git rm -f path/to/submodule

做完以上動作記得也要 commitpush

git commit
git push

其他人 Clone SuperRepo

其他人在 Clone 含有 Submodule 的專案時,要記得在 clone 指令後面加上 --recurse-submodules,否則預設是不會將 submodule clone 下來的。

git clone <repo url> --recurse-submodules

如果是已經 clone 的也可以執行以下指令將 Submodule 拉下來

git submodule init
git submodule update

# or

git submodule update --init

將現有的專案拆出 Submodule

這個部分應該是大多數人都會遇到的,因為很難在專案一開始就切的這個完美,一定是長到一定的量,維護時覺得不行了才開始來切。

要把現有的專案中其中的某個目錄切成 Submodule 其實不難,也不會遺失紀錄。

首先執行拆解的部分,為了保險起見,請另外 Clone 一個乾淨的專案。

git clone <Super Repo>

接下來刪除 origin,因為等等會把 Submodule 推上自己的遠端倉庫,所以這邊就先刪掉原本的 SuperRepo 的遠端倉庫。

git remote rm origin

這個是可選的步驟,如果想要保留 commit 就要執行這個動作,他會過濾掉其他 commit,只保留我們要的 Submodule 的 commit,這就是為什麼一開始要另外 clone 一個新的。

git filter-branch --subdirectory-filter <SubModule folder path> -- --all

執行過程會因為專案大小而有所不同,大一點的專案,且那個 Submodule 包含很多 commit 時,就會執行一段時間。

$ git filter-branch --subdirectory-filter app -- --all

Rewrite 9ca4f14172ddcdf1aa9af5b69338bb41eb56162c (143/152) (6 seconds passed, remaining 0 predicted)

執行完後應該會只剩所選路徑的 commit,並會發現整個根目錄只會剩剛剛所選路徑裡面的東西。

檢查完沒問題後,就可以把要放 Submodule 的倉庫加進來,並推上去了。

git remote add origin <SubModule Repo>
git push

接下來將我們切出來的 SubRepo 加回 SuperRepo,先用 git rm 刪除原本的目錄,在用 git submodule 把他加回來,就像前面提到 “加入 Submodule 步驟” 一樣

git rm -r <folder>
git submodule add <git repository B url> <folder>

確認完沒問題,能跑的都能跑了,要記得 commitpush

git commit
git push

如何使用 Git Subtree ?

我覺得 Subtree 比 Submodule 操作上更簡單,也更容易理解。

章節目錄

新增 Subtree

想要把 SubRepo 用 Subtree 的方式加到 SuperRepo 的話,可以執行以下指令,你必須設定幾個參數, Subtree 的路徑、SubRepo 的遠端倉庫連結及一個版本。

它會將 SubRepo Clone 下來到指定的路徑,並將他 Merge 進我們的 SuperRepo 當前的 HEAD。

git subtree push --prefix <folder path> <repo url> <ref>

小技巧,可以將遠端倉庫 Url 設別名,就像 origin 那樣,就不用每次都要輸入那麼長。

實際執行會是這樣

$ git subtree add --prefix subtree git@github.com:puckwang/SubRepo.git c9c4974e887f2362cc9f7f9d4c90b19891969d67

git fetch git@github.com:puckwang/SubRepo.git c9c4974e887f2362cc9f7f9d4c90b19891969d67
warning: no common commits
remote: Enumerating objects: 6, done.
remote: Counting objects: 100% (6/6), done.
remote: Compressing objects: 100% (3/3), done.
remote: Total 6 (delta 0), reused 0 (delta 0), pack-reused 0
Unpacking objects: 100% (6/6), done.
From github.com:puckwang/SubRepo
 * branch            c9c4974e887f2362cc9f7f9d4c90b19891969d67 -> FETCH_HEAD
Added dir 'subtree'

使用 git status 後,會發現自動發了一個 Merge commit。

$ git status

*   ae3f960 2020-03-19 | Add 'subtree/' from commit 'c9c4974e887f2362cc9f7f9d4c90b19891969d67' (HEAD -> master) [Puck Wang]
|\
| * c9c4974 2020-03-18 | Create feature.txt [Puck Wang]
| * 1a39dc5 2020-03-18 | Initial commit [Puck Wang]
* 6922999 2020-03-19 | Update sub repo (origin/master, origin/HEAD) [Puck Wang]
* 6cb24ae 2020-03-19 | Update sub repo [Puck Wang]
* 811f46a 2020-03-19 | Update subrepo [Puck Wang]
* 73f473c 2020-03-18 | Add sub-repo [Puck Wang]
* 12b9412 2020-03-18 | Initial commit [Puck Wang]

然後,就沒有了… 記得 push

git push

Pull SuperRepo

不影響,他被視為一個資料夾而已,直接下 git pull

Pull Subtree (更新 Subtree 版本)

新增的語法差不多,就只是把 ‘add’ 改成 ‘pull’,其他參數都一樣,執行後他會直接幫你執行 commit 並跳到輸入 message 的畫面。

git subtree pull --prefix <folder path> <repo url> <ref>

實際執行的範例

$ git subtree pull --prefix subtree git@github.com:puckwang/SubRepo.git master
remote: Enumerating objects: 12, done.
remote: Counting objects: 100% (12/12), done.
remote: Compressing objects: 100% (8/8), done.
remote: Total 11 (delta 4), reused 6 (delta 2), pack-reused 0
Unpacking objects: 100% (11/11), done.
From github.com:puckwang/SubRepo
 * branch            master     -> FETCH_HEAD
Merge made by the 'recursive' strategy.
 subtree/123.txt        | 0
 subtree/abc.txt        | 0
 subtree/abc1.txt       | 0
 subtree/feafeat123.ttt | 1 +
 subtree/feature2.txt   | 1 +
 5 files changed, 2 insertions(+)
 create mode 100644 subtree/123.txt
 create mode 100644 subtree/abc.txt
 create mode 100644 subtree/abc1.txt
 create mode 100644 subtree/feafeat123.ttt
 create mode 100644 subtree/feature2.txt

跟剛剛一樣,我們用 git status 檢視,可以看到像這樣的圖,像一個分支一直重複被合併進來。

*   6ba2777 2020-03-19 | Merge commit 'b9f546c57282516102e93e9a5315a7287393e805' (HEAD -> master) [Puck Wang]
|\
| * b9f546c 2020-03-19 | Create feafeat123.ttt [Puck Wang]
| * be3de4b 2020-03-19 | Add 123 [Puck Wang]
| * c47d874 2020-03-19 | abc1.txt [Puck Wang]
| * c2b399c 2020-03-19 | abc.txt [Puck Wang]
| * c86fe17 2020-03-19 | Create feature2.txt [Puck Wang]
* |   ae3f960 2020-03-19 | Add 'subtree/' from commit 'c9c4974e887f2362cc9f7f9d4c90b19891969d67' (origin/master, origin/HEAD) [Puck Wang]
|\ \
| |/
| * c9c4974 2020-03-18 | Create feature.txt [Puck Wang]
| * 1a39dc5 2020-03-18 | Initial commit [Puck Wang]
* 6922999 2020-03-19 | Update sub repo [Puck Wang]
* 6cb24ae 2020-03-19 | Update sub repo [Puck Wang]
* 811f46a 2020-03-19 | Update subrepo [Puck Wang]
* 73f473c 2020-03-18 | Add sub-repo [Puck Wang]
* 12b9412 2020-03-18 | Initial commit [Puck Wang]

Push Subtree

前面比較時有提到 Subtree 再回推時會比較難處理,而他的順序如下:

  1. 在 SuperRepo commit 變更
  2. 在 Subtree push (這會執行比較久)

push 的指令也跟前兩個一樣,改前面而已,後面都一樣。

git subtree push --prefix <folder path> <repo url> <ref>

實際執行範例,過程中會看到他在比對 commit,為多越慢,所以我才說他是比較難處理的。

$ git subtree push --prefix subtree git@github.com:puckwang/SubRepo.git master
git push using:  git@github.com:puckwang/SubRepo.git master
Counting objects: 2, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (2/2), done.
Writing objects: 100% (2/2), 237 bytes | 237.00 KiB/s, done.
Total 2 (delta 1), reused 0 (delta 0)
remote: Resolving deltas: 100% (1/1), completed with 1 local object.
To github.com:puckwang/SubRepo.git
   b9f546c..b7107b3  b7107b38f5416503718a846cf5c85f1ecb5bb1f0 -> master

刪除 subtree

跟 Submodule 相比,刪除 Subtree 算非常間單,就只要把資料夾刪掉就可以了。

其他人 Clone SuperRepo

不影響,他被視為一個資料夾而已,可直接執行 git clone

將現有的專案拆出 Subtree

這個部分也比 Submodule 簡單一點,兩個指令。

首先先把要切出來的資料夾切出來。

git subtree split --prefix <folder path>

接下來直接把他推上遠端倉庫就可以了。

git subtree push --prefix <folder path> <repo url> <ref>

結論

在實際實用兩種方式後,我是比較喜歡 Submodule,因為他不佔空間,實際也就是另一個 Repository,而 Subtree 是整個併進來,就不太喜歡。

但實際上要用那種方式,還是要視當下需求再決定。

參考文章


感謝閱讀!

喜歡這篇文章或是有幫助到你嗎? 歡迎分享給你的朋友!

有任何問題、回饋或您認為我會感興趣的任何東西嗎? 請在下面發表評論,或者是直接聯絡我


Puck Wang

Puck Wang

Hi! 我是 Puck Wang,這個部落格的作者,是一位全端網站開發者,常使用 .Net 和 React 進行開發,專注於架構研究,你可以在這個部落格看到我精選的筆記內容,希望對你會有所幫助。

更多關於我的訊息,可至關於關於頁面。