Git詳解(三) 項目協作

上一篇:Git詳解(二) 遠程操作

裸庫

裸庫即沒有工作區的 Git 倉庫,一般用於服務器上。創建裸庫很簡單,只需要在初始化 Git 倉庫時加上 --bare 參數即可:

git init --bare

創建裸庫 barerepo,查看該目錄下的文件:

可以發現裸庫中沒有 .git 目錄,相對的,原本應該存放在 .git 目錄下的文件則直接出現在 Git 倉庫中。因此我們一般會將裸庫命名爲倉庫名加上 .git 的形式,說到這裏你可能會有似曾相識的感覺。

上一篇文章中在本地添加 github 遠程倉庫,使用到的遠程倉庫 HTTPS 地址和 SSH 地址如下:

HTTPS 地址:https://github.com/BWHN/test.git

SSH 地址:[email protected]:BWHN/test.git

遠程倉庫的命名正是使用 .git 結尾,所以我們在 github 上創建的遠程倉庫是裸庫。

不過光靠一個名字可能無法說服你,因爲將 github 遠程倉庫克隆到本地之後,本地倉庫中是有 .git 目錄的。口說無憑,眼見爲實。下面我們克隆 barerepo 裸庫,即在本地克隆本地倉庫:

從上圖可以看到,克隆裸庫,裸庫目錄下的文件會被放到克隆倉庫中的 .git 目錄下。

所以 github 上創建的倉庫確實是一個裸庫,這也就是爲什麼我在上一篇文章中描述在創建遠程倉庫時使用“相當於”這個詞:

這一步操作相當於在本地創建 testgit 文件夾,然後在文件夾中執行 git init 指令生成 Git 倉庫。

需要注意的是,由於裸庫中沒有工作區,所以我們無法在裸庫中直接提交變更,status 之類的指令也無法使用:

 

submodule

在開發中我們常常會遇到這種情況:某個項目作爲共通被多個其他項目使用。假設共通項目和其他項目都是獨立的項目,應該如何管理這些項目呢?我能想到兩種做法(爲了便於描述,下面將共通項目稱爲子項目,其他項目稱爲父項目)。

一、將子項目打成 jar 包供父項目使用。然而如果子項目不穩定,需要頻繁更新,那麼子項目每次更新都需要重新打包,再上傳到服務器供父項目使用。

二、直接將子項目複製到父項目中使用。但是這種做法無法得知子項目的更新內容,而且你也不知道子項目的下一次更新時間。

針對這個問題,Git 給出的解決方案是子模塊,下面演示子模塊的用法。

首先在 github 上創建兩個倉庫:parent 和 child,然後分別向兩個倉庫進行推送:

   

添加子模塊

相信通過前兩篇文章的學習,克隆倉庫、提交更新、分支推送這些操作你應該已經非常熟練了。下面我們開始學習第一個子模塊相關的指令:

git submodule add <url>

該指令可以爲本地倉庫添加子模塊。默認情況下,子模塊會將子項目放到一個與子項目同名的目錄中。當然你也可以在指令的後面加上目錄名指定子項目存放的目錄: git submodule add <url> <dir> 。

在 parent 倉庫中添加子模塊:child 項目,查看 Git 工作目錄下的文件變化:

從截圖中我們可以看到 “Cloning into ‘child_submodule’” 字樣,這表示添加子模塊的本質上進行的是克隆操作,將遠程 child 倉庫中的內容克隆到本地 parent 倉庫中。但是事實真的這麼簡單嗎?接着查看 Git 工作目錄下的文件變化 —— 出現 child_submodule 目錄和 .gitmodules 文件。而且如果你觀察仔細的話,可以發現新增的目錄和文件已經被提交到了暫存區。

查看 child_submodule 目錄下的文件:

意料之外的是,child_submodule 目錄中沒有 .git 隱藏目錄,取而代之的是 .git 文件。查看 .git 文件可以看到輸出結果是一個路徑,繼續查看給出該路徑下文件:

可以看到該路徑下的文件正是原本應該存在於 .git 目錄中的文件,因此子模塊其實是一個完整的倉庫。

比較 child_submodule 目錄在版本庫和暫存區的不同:

diff 操作的輸出內容並不是我們想象中的結果。輸出結果爲 “Submodule <project_name> <SHA-1>...<SHA-1> (new submodule)” 形式,其含義爲子模塊在本地倉庫的歷史版本的變化信息,由於該子模塊是第一次添加,所以標註這是一個新的子模塊。

這就表示子模塊是一個完整的倉庫,Git 並沒有將子模塊當做一個普通目錄對待,不跟蹤它的內容, 而是跟蹤它的提交記錄變化信息。

因此當我們進入 child_submodule 目錄後,面對的就不再是 parent 倉庫而是 child 倉庫。在該目錄下執行 git log 指令,就可以查看 child 倉庫的提交記錄:

說到這裏,我似乎漏了一個文件沒有介紹:.gitmodules。該文件是首次添加子模塊後 Git 自動創建的,查看該文件的內容:

該文件記錄子項目的 URL 和本地目錄之間的映射關係。如果倉庫中存在多個子模塊,該文件中就會有多條記錄。

需要注意的是,該文件和 .gitignore 文件一樣受到版本控制。它會作爲父項目一部分被拉取和推送,因爲這是克隆該項目的人知道去哪獲得子模塊的依據。

將暫存區的文件提交到版本庫:

注意上圖中 child_submodule 目錄的創建模式爲 160000。這是 Git 中的一種特殊模式,它意味着你是將一次提交記作一項目錄(a directory,我也不知道該怎麼翻譯 ╮(╯▽╰)╭)記錄的,而非將它記錄成一個子目錄或者一個文件。

推送完成後,在 github 上查看推送結果:

從圖中可以看到,child_submodule 目錄後面跟着一個 @ 加上 SHA-1 值表示對 child 項目的引用,點擊之後會直接跳轉到 child 項目對應的那次提交。

克隆含有子模塊的項目

如果克隆一個包含子模塊的項目時,雖然克隆倉庫中會存在子模塊的目錄,但是子模塊的目錄是空的。克隆 parent 項目:

在上一小節“添加子模塊”中,我們知道子模塊的相關的數據存放在 .git/modules 目錄下。查看 .git 目錄,可以看到該目錄中尚沒有 modules 目錄,所以此時 parentclone 倉庫並不包含子模塊相關的數據。同時 .git/config 文件中也沒有子模塊的記錄:

此時我們需要執行兩個指令:

git submodule init

git submodule update

前者負責初始化本地的配置文件,後者負責將子項目中對應的提交抓取到父項目的子模塊目錄中。

執行 git submodule init 指令後,我們可以看到 .git/config 文件中出現子模塊相關的記錄:

繼續執行 git submodule update 指令:

從輸出的結果來看,我們不難發現該指令本質上是將子項目克隆到本地倉庫,然後在本地倉庫進行 checkout 操作。此時子模塊就是父項目提交時的狀態了。

其實像 git submodule initgit submodule update 兩個指令,我們經常會一起執行。可以將它們合併成一步:

git submodule update --init

還有一種更簡單的方法抓取子模塊提交,就是在克隆倉庫的時候加上 --recurse-submodules 參數:

git clone --recurse-submodules <url>

加上該參數後,我們在克隆父項目時,就會自動初始化並更新倉庫中的每一個子模塊,包括可能存在的嵌套子模塊。

說到嵌套子模塊,如果父項目中存在嵌套子模塊,而我們在克隆父項目時沒有使用 --recurse-submodules 參數該怎麼辦呢?如果你已經將父項目克隆到本地,那麼此時你依然可以使用下面的指令抓取嵌套子模塊的提交:

git submodule update --init --recursive

更新子模塊

由於子模塊是一個獨立的項目,如果子項目有更新,在本地父項目倉庫中執行 git fetch 指令並不會抓取子項目的更新。想要獲取子項目的更新,需要進入到子模塊目錄中手動抓取:

此時我們回到父項目,執行 git diff 指令,就可以看到子模塊的變化信息:

正如我在“添加子模塊”小節所說的,Git 將 child_submodule 目錄作爲提交看待,而非一個單純的子目錄或是文件。

若你在進行 diff 操作時加上 --submodule 參數,就可以看到更爲直觀的輸出結果:

當然,如果你覺得執行 diff 操作時輸入 --submodule 一件很繁瑣的事,我們可以將該參數設置爲 diff 的默認行爲:

git config --global diff.submodule log

如果你不想進入子模塊倉庫中手動抓取更新,那麼還有一種更簡單的方式:

git submodule update --remote <submodule>

執行該指令時如果省略子模塊名,Git 默認會嘗試更新所有的子模塊:

需要注意的是,該命令默認更新並檢出子模塊倉庫的 master 分支。如果你還想抓取子項目其他分支的更新,可以在 .git/config 文件或者 .gitmodules 文件中配置。二者的不同在於,配置在 .git/config 文件中只會在本地生效,而配置在 .gitmodules 文件中則會影響團隊中其他抓取該文件更新的人。

git config submodule.child_submodule.branch newb    // 在 .git/config 中配置

git config -f .gitmodules submodule.child_submodule.branch newb   //  在 .gitmodules 中配置

在 .gitmodules 文件中修改默認更新並檢出的分支: 

當我們執行 git submodule update --remote <submodule> 指令抓取子模塊更新之後,進入子模塊目錄查看當前所在分支,你會發現你當前所在的分支是一個遊離的分支:

這就說明該指令不會幫我們自動將更新合併到相應的分支上,合併操作需要我們手動進行。此時將在子模塊倉庫中切換到我們需要分支,再進行合併即可:

或者你可以在抓取子模塊更新之前,將子模塊倉庫所在分支切換到需要更新的分支,然後在執行 git submodule update --remote <submodule> 指令時加上 --merge 參數,就可以在抓取更新的同時將更新合併到對應的分支:

修改子模塊

有時出於父項目的需要我們會修改子模塊,那麼此時需要將子模塊和父項目都推送到遠程倉庫才能保證代碼的正確性。但是我們知道子模塊是一個獨立的項目,在父項目倉庫中執行 git push 指令並不會推送子模塊的更新,想要推送子模塊的的更新需要進入子模塊倉庫中手動進行。

然而有時候我們就是會忘記推送子模塊的修改,這對本地是沒有影響的,因爲子模塊的修改全部都在本地倉庫中放着,但是團隊中其他成員卻無法獲取子模塊的修改。

因此你可以在推送父項目時加上 --recurse-submodules=check 參數,這會讓 Git 在推送到父項目前檢查所有子模塊是否已推送:

如上圖所示,推送時使用該參數,如果子模塊存在新的提交記錄而沒有推送,就會直接導致父項目的推送失敗。此時你需要進入沒有推送的子模塊倉庫中,手動將子模塊的更新推送到遠程倉庫,然後推送父項目。

當然你也可以讓 Git 替你做這件事。在推送父項目時加上 --recurse-submodules=on-demand 參數,如果存在有新的提交記錄而沒有推送的子模塊,Git 嘗試會幫你將子模塊推送到遠程倉庫,然後在推送父項目。如果子模塊推送失敗,父項目也會推送失敗:

遍歷子模塊

設想一下,父項目中如果包含大量的子模塊,如果需要對所有的子模塊進行相同的操作,那一定是一件耗時耗力的操作。好在有一個子模塊命令,可以讓我們遍歷所有的子模塊:foreach。

例如,保存所有子模塊的進度:

刪除子模塊

子模塊的刪除比較繁瑣,大體上分爲 4 個步驟:

  1. 刪除子模塊目錄:git rm <submodule>
  2. 刪除子模塊的本地配置文件:rm -rf .git/modules/<submodule>
  3. 刪除 .gitmodules 文件中子模塊的配置信息
  4. 刪除 .git/config 文件中的配置信息

上面四步完成之後,將父項目推送到遠程倉庫即可。

 

subtree

在 submodule 小節開頭我提到,管理父項目和子項目有兩種方法:第一,將子項目打成 jar 包供父項目使用,子模塊本質上採用的就是這種做法。第二,直接將子項目整個複製到父項目中使用,而這正是子樹解決該問題所採用的方法。

值得一提的是,subtree 並不是 Git 官方開發的,而是 apenwarr 在 GitHub 上開源的一個項目。

同樣的,爲了演示子樹的用法,在 github 上新建兩個倉庫:subtree_parent 和 subtree_child,分別向兩個倉庫進行一次推送:

添加子樹

首先爲 subtree_parent 倉庫添加子項目的遠程倉庫 subtree_child:

此時本地倉庫 subtree_parent 記錄了兩個遠程倉庫,一個是本地倉庫 subtree_parent 對應的遠程倉庫 subtree_parent,另一個則是子項目的遠程倉庫 subtree_child。

爲本地倉庫添加子樹:

git subtree add --prefix=<subtree> <shortname> <remote_branch>

執行該命令後,Git 會將子項目遠程倉庫中指定分支的提交抓取到本地倉庫的子樹目錄中:

查看本地倉庫中的文件信息,可以看到本地倉庫中出現子樹目錄,而子樹目錄下正是子項目遠程倉庫中指定分支提交的文件:

但是如果此時查看 Git 工作目錄下的文件狀態信息,你會驚訝的發現 Git 工作目錄是乾淨的:

取而代之的是,本地倉庫中新增兩條提交記錄。然而至此爲止,我們並沒有進行過任何提交操作,這兩條新增的提交記錄是哪兒來的呢?查看本地倉庫的提交日誌:

從提交日誌中我們看到,一條提交記錄來自子項目,而另一條提交記錄則由本地倉庫的提交記錄和子項目提交記錄合併後生成。看到這裏我們就大概能夠明白,子樹管理子項目的方式就類似於分支合併

此時在 subtree_parent 倉庫中執行 git push 指令,就可以將 subtree_child 目錄作爲父項目的一部分推送到遠程倉庫。而這就是 subtree 和 submodule 根本上的不同之處:

   

左圖爲使用子樹管理子項目,子項目作爲父項目的一部分“嵌入其中”;右圖爲使用子模塊管理子項目,子項目和父項目是兩個完全獨立的項目。

更新子樹

使用下面的指令就可以將子項目遠程倉庫中指定分支的更新抓取到子樹目錄中:

git subtree pull --prefix=<subtree> <shortname> <remote_branch>

如果你覺得 --prefix 參數太長的話,可以用 -P 代替:

和添加子樹的指令一樣,執行該命令也會自動將子項目的提交記錄合併到父項目中:

這樣一來就會帶來一個問題:如果子項目的提交記錄比較多,那麼抓取子項目的更新時,必然會給父項目帶來很多不必要的提交記錄。例如這樣:

爲了避免這個問題,我們可以在抓取子項目更新時加上 --squash 參數,使用該參數可以將多條提交記錄壓縮成一條提交記錄。但是另一方面,不恰當的使用 --squash 參數也會出現各種各樣的問題。

--squash 參數帶來的與失去的

抓取壓縮後的子項目更新:

從上圖中可以看到,subtree/test.txt 文件出現了衝突,但是到現在爲止,我們並沒有在父項目中修改過該文件,只是在抓取子項目更新時加上 --squash 參數,爲什麼會出現衝突呢?查看產生衝突的文件:

解決衝突之後,查看提交日誌:

可以看到 subtree_parent 倉庫中新增兩條提交記錄,一條是解決衝突的提交記錄,另一條則是壓縮子項目提交記錄而產生的提交記錄。

藉助於 gitk(系列第二篇文章中有介紹) 查看提交歷史關係圖:

執行 git subtree pull 指令時,Git 想要將提交記錄 1 合併到提交記錄 2 上。由於 Git 採用三方合併策略進行合併,此時需要找到提交記錄 1 和提交記錄 2 的共同祖先提交,也就是提交記錄 3。但是提交記錄 2 的祖先提交不止一個,提交記錄 4 也是提交記錄 2 的祖先提交之一。

此時 Git 會去找提交記錄 3 和 4 的共同祖先提交,很明顯它們沒有共同的祖先提交,衝突由此產生。

再次抓取子項目的更新,不使用 --squash 參數,衝突會再一次出現:

解決衝突之後,查看提交歷史關係圖:

出現衝突的原因和上一次一樣:進行遞歸三方合併時,找不到兩個提交記錄的共同祖先提交(這裏我就不再分析具體過程了,你可以試着自行分析)。

如果下一次抓取子項目更新時,又使用 --squash 參數,那麼還是會出現衝突。所以使用 --squash 參數最忌諱的就是有時用 --squash 參數,有時不用 --squash 參數。如果有時用有時不用,得到的不是兩者的優點,而是兩者的缺點之和。

推送子樹修改

執行下面的指令,就可以將子樹目錄下的更新推送到子項目的遠程倉庫:

git subtree push --prefix=<subtree> <shortname> <remote_branch>

修改子樹目錄下文件:

先將提交記錄推送到子項目遠程倉庫。推送完成後,我們就可以在子項目遠程倉庫上看到推送結果:

再將本地倉庫中所作的修改推送到父項目遠程倉庫,查看推送結果:

子項目遠程倉庫上推送的提交記錄的 SHA-1 值是 5de7c77,父項目遠程倉庫上的推送提交記錄的 SHA-1 值是 26cdac9,也就是說同一份本地倉庫的提交記錄分別推送到兩個遠程倉庫上,得到的卻是兩個不同的提交記錄。爲什麼會這樣呢?

首先我們要弄清楚 git subtree push 指令爲什麼可以推送子樹目錄下的修改記錄,這是因爲該指令可以將子樹目錄下所做的修改從父項目“分割”出來,而“分割”出來的子樹目錄下的修改是重新生成的提交記錄。

這麼說可能有些難以理解,再舉一個例子。同時修改子樹目錄下的文件和非子樹目錄下的文件:

將提交記錄分別推送到父項目和子項目的遠程倉庫,查看推送結果:

可以看到推送到子項目遠程倉庫的提交記錄,雖然提交說明和推送到父項目遠程倉庫的提交記錄的提交說明一樣,但是提交記錄的文件信息只包含子樹目錄下的文件信息。這就是上文所說的“分割”的含義。

子樹分割

有時我們會遇到這樣的情況:項目中的某個功能模塊需要獨立出來作爲共通項目,讓其他項目也可以使用。一般碰到這種情況,我們的做法是將該功能模塊直接拷貝出來。這樣做是存在弊端的,例如:共通項目的歷史記錄丟失。

子樹給我們提供了一個另一種做法:子樹分割,該指令如下:

git subtree split --prefix=<dir> -b <temp_branch>

執行該指令 Git 會將倉庫目錄下指定目錄內文件的提交記錄拷貝到一條指定分支上,注意這裏的拷貝並不是真正意義上的拷貝,拷貝到指定分支上的提交記錄的 SHA-1 值是重新生成的,這一點和推送子樹修改小節提到的分割是一樣的。

在本地倉庫中新增 Dbconnection 目錄,對該目錄下的文件進行修改、提交:

分割 Dbconnection 模塊:

切換到 dbmodule 分支查看提交歷史:

可以看到 dbmodule 分支上提交記錄的提交說明雖然和 master 分支上一樣,但是 SHA-1 值是不一樣的。

此時我們可以創建一個遠程倉庫,然後將該分支推送到遠程倉庫:

subtree 與 submodule 的選擇

雖然 subtree 是 submodule 的改進方案,但是並不是說 subtree 就優於 submodule。

整體上來說,submodule 上手不如 subtree 簡單,但是 subtree 需要比 submodule 佔用更大的空間。

總之還是那句話,沒有最好的,只有最合適的。

 

參考:

Git 本地倉庫和裸倉庫

Git-工具-子模塊

Git subtree 要不要使用 –squash 參數

Git 切割方法:subtree

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章