Git應用詳解第十講:Git子庫:submodule與subtree
前言
一個中大型項目往往會依賴幾個模組,git提供了子庫的概念。可以將這些子模組存放在不同的倉庫中,通過submodule或subtree實現倉庫的嵌套。本講為Git應用詳解的倒數第二講,勝利離我們不遠了!

一、submodule
submodule:子模組的意思,表示將一個版本庫作為子庫引入到另一個版本庫中:

1.引入子庫
需要使用如下命令:
git submodule add 子庫地址 保存目錄
比如:
git submodule add [email protected]:AhuntSun/git_child.git mymodule
執行上述命令會將地址對應的遠程倉庫作為子庫,保存到當前版本庫的mymodule目錄下:

隨後查看當前版本庫的狀態:

可以發現新增了兩個文件。查看其中的.gitmodules文件:

可以看到當前文件的路徑和子模組的url,隨後將這兩個新增文件添加、提交並推送。在當前倉庫git_parent對應的遠程倉庫中多出了兩個文件:

其中mymodule文件夾上的3bd7f76 對應的是子倉庫git_child中的最新提交:

點擊mymodule文件夾,會自動跳轉到子倉庫中:

通過上述分析,可以得出結論:兩個倉庫已經關聯起來了,並且倉庫git_child為倉庫git_parent的子倉庫;
2.同步子庫變化
當被依賴的子版本庫發生變化時:在子版本庫git_child中新增文件world.txt並提交到遠程倉庫:

這個時候依賴它的父版本庫git_parent要如何感知這一變化呢?
方法一
這個時候git_parent只需要進入存放子庫git_child的目錄mymodule,執行git pull就能將子版本庫git_child的更新拉取到本地:

方法二
當父版本庫git_parent依賴的多個子版本庫都發生變化時,可以採用如下方法遍歷更新所有子庫:首先回到版本庫主目錄,執行以下指令:
git submodule foreach git pull
該命令會遍歷當前版本庫所依賴的所有子版本庫,並將它們的更新拉取到父版本庫git_parent:

拉取完成後,查看狀態,發現mymodule目錄下文件發生了變化,所以需要執行一次添加、提交、推送操作:

3.複製父版本庫
如果將使用了submodule添加依賴了子庫的父版本庫git_parent,克隆一份到本地的話。在克隆出來的新版本庫git_parent2中,原父版本庫存放依賴子庫的目錄雖在,但是內容不在:

進入根據git_parent複製出來的倉庫git_parent2,會發現mymodule目錄為空:

解決方法:可採用多條命令的分步操作,也可以通過參數將多步操作進行合併。
分步操作
這是在執行了clone操作後的額外操作,還需要做兩件事:
-
手動初始化
submodule:git submodule init -
手動拉取依賴的子版本庫;:
git submodule update --recursive

執行完兩步操作後,子版本庫中就有內容了。由此完成了git_parent的克隆;
合併操作
分步操作相對繁瑣,還可以通過添加參數的方式,將多步操作進行合併。通過以下指令基於git_parent克隆一份git_parent3:
git clone [email protected]:AhuntSun/git_parent.git git_parent3 --recursive

--recursive表示遞歸地克隆git_parent依賴的所有子版本庫。
4.刪除子版本庫
git沒有提供直接刪除submodule子庫的命令,但是我們可以通過其他指令的組合來達到這一目的,分為三步:
-
將
submodule從版本庫中刪除:git rm --cache mymodule

git rm的作用為刪除版本庫中的文件,並將這一操作納入暫存區;
- 將
submodule從工作區中刪除;

- 最後將
.gitmodules目錄刪除;

完成三步操作後,再進行添加,提交,推送即可完成刪除子庫的操作:

二、subtree
1.簡介
subtree與submodule的作用是一樣的,但是subtree出現得比submodule晚,它的出現是為了彌補submodule存在的問題:
- 第一:
submodule不能在父版本庫中修改子版本庫的程式碼,只能在子版本庫中修改,是單向的; - 第二:
submodule沒有直接刪除子版本庫的功能;
而subtree則可以實現雙向數據修改。官方推薦使用subtree替代submodule。
2.創建子庫
首先創建兩個版本庫:git_subtree_parent和git_subtree_child然後在git_subtree_parent中執行git subtree會列出該指令的一些常見的參數:

3.建立關聯
首先需要給git_subtree_parent添加一個子庫git_subtree_child:
第一步:添加子庫的遠程地址:
git remote add subtree-origin [email protected]:AhuntSun/git_subtree_child.git
添加完成後,父版本庫中就有兩個遠程地址了:

這裡的subtree-origin就代表了遠程倉庫git_subtree_child的地址。
第二步:建立依賴關係:
git subtree add --prefix=subtree subtree-origin master --squash
//其中的--prefix=subtree可以寫成:--p subtree 或 --prefix subtree
該命令表示將遠程地址為subtree-origin的,子版本庫上master分支的,文件克隆到subtree目錄下;
注意:是在某一分支(如
master)上將subtree-origin代表的遠程倉庫的某一分支(如master)作為子庫拉取到subtree文件夾中。可切換到其他分支重複上述操作,也就是說子庫的實質就是子分支。
--squash是可選參數,它的含義是合併,壓縮的意思。
- 如果不增加這個參數,則會把遠程的子庫中指定的分支(這裡是
master)中的提交一個一個地拉取到本地再去創建一個合併提交; - 如果增加了這個參數,會將遠程子庫指定分支上的多次提交合併壓縮成一次提交再拉取到本地,這樣拉取到本地的,遠程子庫中的,指定分支上的,歷史提交記錄就沒有了。

拉取完成後,父版本庫中會增添一個subtree目錄,裡面是子庫的文件,相當於把依賴的子庫程式碼拉取到了本地:

此時查看一下父版本庫的提交歷史:

會發現其中沒有子庫李四的提交資訊,這是因為--squash參數將他的提交壓縮為一次提交,並由父版本庫張三進行合併和提交。所以父版本庫多出了兩次提交。
隨後,我們在父版本庫中進行一次推送:

結果遠程倉庫中多出了一個存放子版本庫文件的subtree目錄,並且完全脫離了版本庫git_subtree_child,僅僅是屬於父版本庫git_subtree_parent的一個目錄。而不像使用submodule那樣,是一個點擊就會自動跳轉到依賴子庫的指針:
subtree的遠程父版本庫:

submodule的遠程父版本庫:

即submodule與subtree子庫的區別為:

4.同步子庫變化
在子庫中創建一個新文件world並推送到遠程子庫:

在父庫中通過如下指令更新依賴的子庫內容:
git subtree pull --prefix=subtree subtree-origin master --squash

此時查看一下提交歷史:

發現沒有子庫李四的提交資訊,這都是--squash的作用。子庫的修改交由父庫來提交。
5.參數--squash
該參數的作用為:防止子庫指定分支上的提交歷史污染父版本庫。比如在子庫的master分支上進行了三次提交分別為:a、b、c,並推送到遠程子庫。
首先,複習一下合併分支時遵循的三方合併原則:

當提交4和6需要合併的時候,git會先尋找二者的公共父提交節點,如圖中的2,然後在提交2的基礎上進行2、4、6的三方合併,合併後得到提交7。
父倉庫執行pull操作時:如果添加參數--squash,就會把遠程子庫master分支上的這三次提交合併為一次新的提交abc;隨後再與父倉庫中子庫的master分支進行合併,又產生一次提交X。整個pull的過程一共產生了五次提交,如下圖所示:

存在的問題:
由於--squash指令的合併操作,會導致遠程master分支上的合併提交abc與本地master分支上的最新提交2,找不到公共父節點,從而合併失敗。同時push操作也會出現額外的問題。
最佳實踐:要麼全部操作都使用--squash指令,要麼全部操作都不使用該參數,這樣就不會出錯。
錯誤示範:
為了驗證,重新創建兩個倉庫A和B,並通過subtree將B設置為A的子庫。這次全程都沒有使用參數--squash,重複上述操作:
- 首先,修改子庫文件;
- 然後,通過下列指令,在不使用參數
--squash的情況下,將遠程子庫A變化的文件拉取到本地:
git subtree pull --prefix=subtree subtree-origin master

此時查看提交歷史:

可以看到子庫兒子的提交資訊污染了父版本庫的提交資訊,驗證了上述的結論。
所以要麼都使用該指令,要麼都不使用才能避免錯誤;如果不需要子庫的提交日誌,推薦使用--squash指令。
補充:
echo 'new line' >> test.txt:表示在test.txt文件末尾追加文本new line;如果是一個>表示替換掉test.txt內的全部內容。
6.修改子庫
subtree的強大之處在於,它可以在父版本庫中修改依賴的子版本庫。以下為演示:
進入父版本庫存放子庫的subtree目錄,修改子庫文件child.txt,並推送到遠程父倉庫:

此時遠程父版本庫中存放子庫文件的subtree目錄發生了變化,但是獨立的遠程子庫git_subtree_child並沒有發生變化。
-
修改獨立的遠程子庫:
可執行以下命令,同步地修改遠程子版本庫:
git subtree push --prefix=subtree subtree-origin master如下圖所示,父庫中的子庫文件
child.txt新增的child2內容,同步到了獨立的遠程子庫中:
-
修改獨立的本地子庫:
回到本地子庫
git_subtree_child,將對應的遠程子庫進行的修改拉取到本地進行合併同步:
由此無論是遠程的還是本地的子庫都被修改了。
實際上使用
subtree後,在外部看起來父倉庫和子倉庫是一個整體的倉庫。執行clone操作時,不會像submodule那樣需要遍歷子庫來單獨克隆。而是可以將整個父倉庫和它所依賴的子庫當做一個整體進行克隆。
存在的問題
父版本庫拉取遠程子庫進行更新同步會出現的問題:
-
子倉庫第一次修改:
經歷了上述操作,本地子庫與遠程子庫的文件達到了同步,其中文件
child.txt的內容都是child~4。在此基礎上本地子庫為該文件添加child5~6:
然後推送到遠程子庫。
-
父倉庫第一次拉取:
隨後父版本庫通過下述指令,拉取遠程子庫,與本地父倉庫
git_subtree_parent中的子庫進行同步:git subtree pull --p subtree subtree-origin master --squash結果出現了合併失敗的情況:

我們查看衝突產生的文件:

發現父版本庫中的子庫與遠程子庫內容上並無衝突,但是卻發生了衝突,這是為什麼呢?
探究衝突產生的原因之前我們先解決衝突,先刪除多餘的內容:

隨後執行
git add命令和git commit命令標識解決了衝突:

解決完衝突後將該文件推送到獨立的遠程子庫,發現文件並沒有發生更新,也就是說
git認為我們並沒有解決衝突:
-
子倉庫第二次修改與父倉庫第二次拉取:
再次修改本地子庫的文件並推送到對應的遠程倉庫,父版本庫再次將遠程子庫更新的文件拉取到本地進行同步:

這次卻成功了!為什麼同樣的操作,有的時候成功有的時候失敗呢?
解決方案
原因出現在--squash指令中。實際上,--squash指令把子庫中的提交資訊合併了,導致父倉庫在執行git pull操作時找不到公共的父節點,從而導致即使文件沒有衝突的內容,也會出現合併衝突的情況。其實不使用--squash也會有這種問題,問題的根本原因仍然是三方合併時找不到公共父節點。我們打開gitk:

從圖中不難看出,當使用subtree時,子庫與父庫之間是沒有公共節點的,所以時常會因為找不到公共節點而出現合併衝突的情況,此時只需要解決衝突,手動合併即可。
不使用
subtree時,普通的版本庫中的各分支總會有一個公共節點:
再次強調:使用--squash指令時一定要小心,要麼都使用它,要麼都不使用。
7.抽離子庫
git subtree split
當開發過程中出現某些子庫完全可以復用到其他項目中時,我們希望將它獨立出來。
- 方法一:可以手動將文件拷貝出來。缺點是,這樣會丟失關於該子庫的提交記錄;
- 方法二:使用
git subtree split指令,該指令會把關於獨立出來的子庫的每次提交都記錄起來。但是,這樣存在弊端:- 比如該獨立子庫為
company.util,當一次提交同時修改了company.util和company.server兩個子庫時。 - 通過上述命令獨立出來的子庫
util只會記錄對自身修改的提交,而不會記錄對company.server的修改,這樣在別人看來這次提交就只修改了util,這是不完整的。
- 比如該獨立子庫為
以上就是本講的全部內容,主要介紹了
git子庫的基本使用方法。下一講將是Git應用詳解系列的完結篇:Git工作流Gitflow。我們下一講再見!












