原文:《Pro Git》
Git 分支
幾乎每一種版本控制系統都以某種形式支援分支。使用分支意味著你可以從開發主線上分離開來,然後在不影響主線的同時繼續工作。在很多版本控制系統中,這是個昂貴的過程,常常需要建立一個原始碼目錄的完整副本,對大型專案來說會花費很長時間。(伯樂線上注:如果你對Git還不瞭解,建議從本Git系列第一篇文章開始閱讀)
有人把 Git 的分支模型稱為“必殺技特性”,而正是因為它,將 Git 從版本控制系統家族裡區分出來。Git 有何特別之處呢?Git 的分支可謂是難以置信的輕量級,它的新建操作幾乎可以在瞬間完成,並且在不同分支間切換起來也差不多一樣快。和許多其他版本控制系統不同,Git 鼓勵在工作流程中頻繁使用分支與合併,哪怕一天之內進行許多次都沒有關係。理解分支的概念並熟練運用後,你才會意識到為什麼 Git 是一個如此強大而獨特的工具,並從此真正改變你的開發方式。
3.1 何謂分支
為了理解 Git 分支的實現方式,我們需要回顧一下 Git 是如何儲存資料的。或許你還記得第一章的內容,Git 儲存的不是檔案差異或者變化量,而只是一系列檔案快照。
在 Git 中提交時,會儲存一個提交(commit)物件,該物件包含一個指向暫存內容快照的指標,包含本次提交的作者等相關附屬資訊,包含零個或多個指向該提交對 象的父物件指標:首次提交是沒有直接祖先的,普通提交有一個祖先,由兩個或多個分支合併產生的提交則有多個祖先。
為直觀起見,我們假設在工作目錄中有三個檔案,準備將它們暫存後提交。暫存操作會對每一個檔案計算校驗和(即第一章中提到的 SHA-1 雜湊字串),然後把當前版本的檔案快照儲存到 Git 倉庫中(Git 使用 blob 型別的物件儲存這些快照),並將校驗和加入暫存區域:
1 2 |
$ git add README test.rb LICENSE $ git commit -m 'initial commit of my project' |
當使用 git commit新建一個提交物件前,Git 會先計算每一個子目錄(本例中就是專案根目錄)的校驗和,然後在 Git 倉庫中將這些目錄儲存為樹(tree)物件。之後 Git 建立的提交物件,除了包含相關提交資訊以外,還包含著指向這個樹物件(專案根目錄)的指標,如此它就可以在將來需要的時候,重現此次快照的內容了。
現在,Git 倉庫中有五個物件:三個表示檔案快照內容的 blob 物件;一個記錄著目錄樹內容及其中各個檔案對應 blob 物件索引的 tree 物件;以及一個包含指向 tree 物件(根目錄)的索引和其他提交資訊後設資料的 commit 物件。概念上來說,倉庫中的各個物件儲存的資料和相互關係看起來如圖 3-1 所示:
圖 3-1. 單個提交物件在倉庫中的資料結構
作些修改後再次提交,那麼這次的提交物件會包含一個指向上次提交物件的指標(譯註:即下圖中的 parent 物件)。兩次提交後,倉庫歷史會變成圖 3-2 的樣子:
圖 3-2. 多個提交物件之間的連結關係
現在來談分支。Git 中的分支,其實本質上僅僅是個指向 commit 物件的可變指標。Git 會使用 master 作為分支的預設名字。在若干次提交後,你其實已經有了一個指向最後一次提交物件的 master 分支,它在每次提交的時候都會自動向前移動。
圖 3-3. 分支其實就是從某個提交物件往回看的歷史
那麼,Git 又是如何建立一個新的分支的呢?答案很簡單,建立一個新的分支指標。比如新建一個 testing 分支,可以使用git branch命令:
1 |
$ git branch testing |
這會在當前 commit 物件上新建一個分支指標(見圖 3-4)。
圖 3-4. 多個分支指向提交資料的歷史
那麼,Git 是如何知道你當前在哪個分支上工作的呢?其實答案也很簡單,它儲存著一個名為 HEAD 的特別指標。請注意它和你熟知的許多其他版本控制系統(比如 Subversion 或 CVS)裡的 HEAD 概念大不相同。在 Git 中,它是一個指向你正在工作中的本地分支的指標(譯註:將 HEAD 想象為當前分支的別名。)。執行git branch
命令,僅僅是建立了一個新的分支,但不會自動切換到這個分支中去,所以在這個例子中,我們依然還在 master 分支裡工作(參考圖 3-5)。
圖 3-5. HEAD 指向當前所在的分支
要切換到其他分支,可以執行 git checkout命令。我們現在轉換到新建的 testing 分支:
1 |
$ git checkout testing |
這樣 HEAD 就指向了 testing 分支(見圖3-6)。
圖 3-6. HEAD 在你轉換分支時指向新的分支
這樣的實現方式會給我們帶來什麼好處呢?好吧,現在不妨再提交一次:
$ vim test.rb $ git commit -a -m ‘made a change’
圖 3-7 展示了提交後的結果。
圖 3-7. 每次提交後 HEAD 隨著分支一起向前移動
非常有趣,現在 testing 分支向前移動了一格,而 master 分支仍然指向原先 git checkout
時所在的 commit 物件。現在我們回到 master 分支看看:
1 |
$ git checkout master |
圖 3-8 顯示了結果。
圖 3-8. HEAD 在一次 checkout 之後移動到了另一個分支
這條命令做了兩件事。它把 HEAD 指標移回到 master 分支,並把工作目錄中的檔案換成了 master 分支所指向的快照內容。也就是說,現在開始所做的改動,將始於本專案中一個較老的版本。它的主要作用是將 testing 分支裡作出的修改暫時取消,這樣你就可以向另一個方向進行開發。
我們作些修改後再次提交:
1 2 |
$ vim test.rb $ git commit -a -m 'made other changes' |
現在我們的專案提交歷史產生了分叉(如圖 3-9 所示),因為剛才我們建立了一個分支,轉換到其中進行了一些工作,然後又回到原來的主分支進行了另外一些工作。這些改變分別孤立在不同的分支裡:我們可以 在不同分支裡反覆切換,並在時機成熟時把它們合併到一起。而所有這些工作,僅僅需要branch
和 checkout
這兩條命令就可以完成。
圖 3-9. 不同流向的分支歷史
由於 Git 中的分支實際上僅是一個包含所指物件校驗和(40 個字元長度 SHA-1 字串)的檔案,所以建立和銷燬一個分支就變得非常廉價。說白了,新建一個分支就是向一個檔案寫入 41 個位元組(外加一個換行符)那麼簡單,當然也就很快了。
這和大多數版本控制系統形成了鮮明對比,它們管理分支大多采取備份所有專案檔案到特定目錄的方式,所以根據專案檔案數量和大小不同,可能花費的時間 也會有相當大的差別,快則幾秒,慢則數分鐘。而 Git 的實現與專案複雜度無關,它永遠可以在幾毫秒的時間內完成分支的建立和切換。同時,因為每次提交時都記錄了祖先資訊(譯註:即parent
物件),將來要合併分支時,尋找恰當的合併基礎(譯註:即共同祖先)的工作其實已經自然而然地擺在那裡了,所以實現起來非常容易。Git 鼓勵開發者頻繁使用分支,正是因為有著這些特性作保障。
接下來看看,我們為什麼應該頻繁使用分支。
3.2 分支的新建與合併
現在讓我們來看一個簡單的分支與合併的例子,實際工作中大體也會用到這樣的工作流程:
1. 開發某個網站。 2. 為實現某個新的需求,建立一個分支。 3. 在這個分支上開展工作。
假設此時,你突然接到一個電話說有個很嚴重的問題需要緊急修補,那麼可以按照下面的方式處理:
1. 返回到原先已經發布到生產伺服器上的分支。 2. 為這次緊急修補建立一個新分支,並在其中修復問題。 3. 通過測試後,回到生產伺服器所在的分支,將修補分支合併進來,然後再推送到生產伺服器上。 4. 切換到之前實現新需求的分支,繼續工作。
分支的新建與切換
首先,我們假設你正在專案中愉快地工作,並且已經提交了幾次更新(見圖 3-10)。
圖 3-10. 一個簡短的提交歷史
現在,你決定要修補問題追蹤系統上的 #53 問題。順帶說明下,Git 並不同任何特定的問題追蹤系統打交道。這裡為了說明要解決的問題,才把新建的分支取名為 iss53。要新建並切換到該分支,執行git checkout 並加上 -b 引數:
1 2 |
$ git checkout -b iss53 Switched to a new branch "iss53" |
這相當於執行下面這兩條命令:
1 2 |
$ git branch iss53 $ git checkout iss53 |
圖 3-11 示意該命令的執行結果。
圖 3-11. 建立了一個新分支的指標
接著你開始嘗試修復問題,在提交了若干次更新後,iss53分支的指標也會隨著向前推進,因為它就是當前分支(換句話說,當前的 HEAD 指標正指向 iss53,見圖 3-12):
1 2 |
$ vim index.html $ git commit -a -m 'added a new footer [issue 53]' |
圖 3-12. iss53 分支隨工作進展向前推進
現在你就接到了那個網站問題的緊急電話,需要馬上修補。有了 Git ,我們就不需要同時釋出這個補丁和 iss53 裡作出的修改,也不需要在建立和釋出該補丁到伺服器之前花費大力氣來複原這些修改。唯一需要的僅僅是切換回master 分支。
不過在此之前,留心你的暫存區或者工作目錄裡,那些還沒有提交的修改,它會和你即將檢出的分支產生衝突從而阻止 Git 為你切換分支。切換分支的時候最好保持一個清潔的工作區域。稍後會介紹幾個繞過這種問題的辦法(分別叫做 stashing 和 commit amending)。目前已經提交了所有的修改,所以接下來可以正常轉換到master 分支:
1 2 |
$ git checkout master Switched to branch "master" |
此時工作目錄中的內容和你在解決問題 #53 之前一模一樣,你可以集中精力進行緊急修補。這一點值得牢記:Git 會把工作目錄的內容恢復為檢出某分支時它所指向的那個提交物件的快照。它會自動新增、刪除和修改檔案以確保目錄的內容和你當時提交時完全一樣。
接下來,你得進行緊急修補。我們建立一個緊急修補分支 hotfix 來開展工作,直到搞定(見圖 3-13):
1 2 3 4 5 6 |
$ git checkout -b 'hotfix' Switched to a new branch "hotfix" $ vim index.html $ git commit -a -m 'fixed the broken email address' [hotfix]: created 3a0874c: "fixed the broken email address" 1 files changed, 0 insertions(+), 1 deletions(-) |
圖 3-13. hotfix 分支是從 master 分支所在點分化出來的
有必要作些測試,確保修補是成功的,然後回到 master 分支並把它合併進來,然後釋出到生產伺服器。用 git merge 命令來進行合併:
1 2 3 4 5 6 |
$ git checkout master $ git merge hotfix Updating f42c576..3a0874c Fast forward README | 1 - 1 files changed, 0 insertions(+), 1 deletions(-) |
請注意,合併時出現了“Fast forward”的提示。由於當前 master 分支所在的提交物件是要併入的 hotfix 分支的直接上游,Git 只需把master 分支指標直接右移。換句話說,如果順著一個分支走下去可以到達另一個分支的話,那麼 Git 在合併兩者時,只會簡單地把指標右移,因為這種單線的歷史分支不存在任何需要解決的分歧,所以這種合併過程可以稱為快進(Fast forward)。
現在最新的修改已經在當前 master 分支所指向的提交物件中了,可以部署到生產伺服器上去了(見圖 3-14)。
圖 3-14. 合併之後,master 分支和 hotfix 分支指向同一位置。
在那個超級重要的修補釋出以後,你想要回到被打擾之前的工作。由於當前 hotfix 分支和 master 都指向相同的提交物件,所以hotfix 已經完成了歷史使命,可以刪掉了。使用 git branch 的 -d 選項執行刪除操作:
1 2 |
$ git branch -d hotfix Deleted branch hotfix (3a0874c). |
現在回到之前未完成的 #53 問題修復分支上繼續工作(圖 3-15):
1 2 3 4 5 6 |
$ git checkout iss53 Switched to branch "iss53" $ vim index.html $ git commit -a -m 'finished the new footer [issue 53]' [iss53]: created ad82d7a: "finished the new footer [issue 53]" 1 files changed, 1 insertions(+), 0 deletions(-) |
圖 3-15. iss53 分支可以不受影響繼續推進。
不用擔心之前 hotfix 分支的修改內容尚未包含到 iss53 中來。如果確實需要納入此次修補,可以用git merge master 把 master 分支合併到 iss53;或者等 iss53 完成之後,再將iss53 分支中的更新併入 master。
分支的合併
在問題 #53 相關的工作完成之後,可以合併回 master 分支。實際操作同前面合併 hotfix 分支差不多,只需回到master分支,執行 git merge 命令指定要合併進來的分支:
1 2 3 4 5 |
$ git checkout master $ git merge iss53 Merge made by recursive. README | 1 + 1 files changed, 1 insertions(+), 0 deletions(-) |
請注意,這次合併操作的底層實現,並不同於之前 hotfix 的併入方式。因為這次你的開發歷史是從更早的地方開始分叉的。由於當前 master 分支所指向的提交物件(C4)並不是 iss53 分支的直接祖先,Git 不得不進行一些額外處理。就此例而言,Git 會用兩個分支的末端(C4 和 C5)以及它們的共同祖先(C2)進行一次簡單的三方合併計算。圖 3-16 用紅框標出了 Git 用於合併的三個提交物件:
圖 3-16. Git 為分支合併自動識別出最佳的同源合併點。
這次,Git 沒有簡單地把分支指標右移,而是對三方合併後的結果重新做一個新的快照,並自動建立一個指向它的提交物件(C6)(見圖 3-17)。這個提交物件比較特殊,它有兩個祖先(C4 和 C5)。
值得一提的是 Git 可以自己裁決哪個共同祖先才是最佳合併基礎;這和 CVS 或 Subversion(1.5 以後的版本)不同,它們需要開發者手工指定合併基礎。所以此特性讓 Git 的合併操作比其他系統都要簡單不少。
圖 3-17. Git 自動建立了一個包含了合併結果的提交物件。
既然之前的工作成果已經合併到 master 了,那麼 iss53 也就沒用了。你可以就此刪除它,並在問題追蹤系統裡關閉該問題。
1 |
$ git branch -d iss53 |
遇到衝突時的分支合併
有時候合併操作並不會如此順利。如果在不同的分支中都修改了同一個檔案的同一部分,Git 就無法乾淨地把兩者合到一起(譯註:邏輯上說,這種問題只能由人來裁決。)。如果你在解決問題 #53 的過程中修改了hotfix 中修改的部分,將得到類似下面的結果:
1 2 3 4 |
$ git merge iss53 Auto-merging index.html CONFLICT (content): Merge conflict in index.html Automatic merge failed; fix conflicts and then commit the result. |
Git 作了合併,但沒有提交,它會停下來等你解決衝突。要看看哪些檔案在合併時發生衝突,可以用 git status 查閱:
1 2 3 4 5 6 7 8 9 10 11 |
[master*]$ git status index.html: needs merge # On branch master # Changed but not updated: # (use "git add ..." to update what will be committed) # (use "git checkout -- ..." to discard changes in working directory) # # unmerged: index.html # |
任何包含未解決衝突的檔案都會以未合併(unmerged)的狀態列出。Git 會在有衝突的檔案里加入標準的衝突解決標記,可以通過它們來手工定位並解決這些衝突。可以看到此檔案包含類似下面這樣的部分:
可以看到 ======= 隔開的上半部分,是 HEAD(即 master 分支,在執行merge 命令時所切換到的分支)中的內容,下半部分是在 iss53 分支中的內容。解決衝突的辦法無非是二者選其一或者由你親自整合到一起。比如你可以通過把這段內容替換為下面這樣來解決:
這個解決方案各採納了兩個分支中的一部分內容,而且我還刪除了 <<<<<<<,======= 和 >>>>>>> 這些行。在解決了所有檔案裡的所有衝突後,執行 git add 將把它們標記為已解決狀態(譯註:實際上就是來一次快照儲存到暫存區域。)。因為一旦暫存,就表示衝突已經解決。如果你想用一個有圖形介面的工具來解決這些問題,不妨執行git mergetool,它會呼叫一個視覺化的合併工具並引導你解決所有衝突:
1 2 3 4 5 6 7 |
$ git mergetool merge tool candidates: kdiff3 tkdiff xxdiff meld gvimdiff opendiff emerge vimdiff Merging the files: index.html Normal merge conflict for 'index.html': {local}: modified {remote}: modified Hit return to start merge resolution tool (opendiff): |
如果不想用預設的合併工具(Git 為我預設選擇了 opendiff,因為我在 Mac 上執行了該命令),你可以在上方”merge tool candidates”裡找到可用的合併工具列表,輸入你想用的工具名。我們將在第七章討論怎樣改變環境中的預設值。
退出合併工具以後,Git 會詢問你合併是否成功。如果回答是,它會為你把相關檔案暫存起來,以表明狀態為已解決。
再執行一次 git status 來確認所有衝突都已解決:
1 2 3 4 5 6 7 8 |
$ git status # On branch master # Changes to be committed: # (use "git reset HEAD ..." to unstage) # # modified: index.html # |
如果覺得滿意了,並且確認所有衝突都已解決,也就是進入了暫存區,就可以用 git commit 來完成這次合併提交。提交的記錄差不多是這樣:
1 2 3 4 5 6 7 8 |
Merge branch 'iss53' Conflicts: index.html # # It looks like you may be committing a MERGE. # If this is not correct, please remove the file # .git/MERGE_HEAD # and try again. # |
如果想給將來看這次合併的人一些方便,可以修改該資訊,提供更多合併細節。比如你都作了哪些改動,以及這麼做的原因。有時候裁決衝突的理由並不直接或明顯,有必要略加註解。
3.3 分支的管理
到目前為止,你已經學會了如何建立、合併和刪除分支。除此之外,我們還需要學習如何管理分支,在日後的常規工作中會經常用到下面介紹的管理命令。
git branch 命令不僅僅能建立和刪除分支,如果不加任何引數,它會給出當前所有分支的清單:
1 2 3 4 |
$ git branch iss53 * master testing |
注意看 master 分支前的 * 字元:它表示當前所在的分支。也就是說,如果現在提交更新,master 分支將隨著開發進度前移。若要檢視各個分支最後一個提交物件的資訊,執行git branch -v:
1 2 3 4 |
$ git branch -v iss53 93b412c fix javascript issue * master 7a98805 Merge branch 'iss53' testing 782fd34 add scott to the author list in the readmes |
要從該清單中篩選出你已經(或尚未)與當前分支合併的分支,可以用 –merge 和 –no-merged 選項(Git 1.5.6 以上版本)。比如用git branch –merge 檢視哪些分支已被併入當前分支(譯註:也就是說哪些分支是當前分支的直接上游。):
1 2 3 |
$ git branch --merged iss53 * master |
之前我們已經合併了 iss53,所以在這裡會看到它。一般來說,列表中沒有 * 的分支通常都可以用 git branch -d 來刪掉。原因很簡單,既然已經把它們所包含的工作整合到了其他分支,刪掉也不會損失什麼。
另外可以用 git branch –no-merged 檢視尚未合併的工作:
1 2 |
$ git branch --no-merged testing |
它會顯示還未合併進來的分支。由於這些分支中還包含著尚未合併進來的工作成果,所以簡單地用 git branch -d 刪除該分支會提示錯誤,因為那樣做會丟失資料:
1 2 3 |
$ git branch -d testing error: The branch 'testing' is not an ancestor of your current HEAD. If you are sure you want to delete it, run 'git branch -D testing'. |
不過,如果你確實想要刪除該分支上的改動,可以用大寫的刪除選項 -D 強制執行,就像上面提示資訊中給出的那樣。
3.4 利用分支進行開發的工作流程
現在我們已經學會了新建分支和合並分支,可以(或應該)用它來做點什麼呢?在本節,我們會介紹一些利用分支進行開發的工作流程。而正是由於分支管理的便捷,才衍生出了這類典型的工作模式,你可以根據專案的實際情況選擇一種用用看。
長期分支
由於 Git 使用簡單的三方合併,所以就算在較長一段時間內,反覆多次把某個分支合併到另一分支,也不是什麼難事。也就是說,你可以同時擁有多個開放的分支,每個分支用於完成特定的任務,隨著開發的推進,你可以隨時把某個特性分支的成果併到其他分支中。
許多使用 Git 的開發者都喜歡用這種方式來開展工作,比如僅在 master 分支中保留完全穩定的程式碼,即已經發布或即將釋出的程式碼。與此同時,他們還有一個名為develop 或 next 的平行分支,專門用於後續的開發,或僅用於穩定性測試 — 當然並不是說一定要絕對穩定,不過一旦進入某種穩定狀態,便可以把它合併到master 裡。這樣,在確保這些已完成的特性分支(短期分支,比如之前的 iss53 分支)能夠通過所有測試,並且不會引入更多錯誤之後,就可以併到主幹分支中,等待下一次的釋出。
本質上我們剛才談論的,是隨著提交物件不斷右移的指標。穩定分支的指標總是在提交歷史中落後一大截,而前沿分支總是比較靠前(見圖 3-18)。
圖 3-18. 穩定分支總是比較老舊。
或者把它們想象成工作流水線,或許更好理解一些,經過測試的提交物件集合被遴選到更穩定的流水線(見圖 3-19)。
圖 3-19. 想象成流水線可能會容易點。
你可以用這招維護不同層次的穩定性。某些大專案還會有個 proposed(建議)或 pu(proposed updates,建議更新)分支,它包含著那些可能還沒有成熟到進入next 或 master 的內容。這麼做的目的是擁有不同層次的穩定性:當這些分支進入到更穩定的水平時,再把它們合併到更高層分支中去。再次說明下,使用多個長期分支的做法並非必需,不過一般來說,對於特大型專案或特複雜的專案,這麼做確實更容易管理。
特性分支
在任何規模的專案中都可以使用特性(Topic)分支。一個特性分支是指一個短期的,用來實現單一特性或與其相關工作的分支。可能你在以前的版本控 制系統裡從未做過類似這樣的事情,因為通常建立與合併分支消耗太大。然而在 Git 中,一天之內建立、使用、合併再刪除多個分支是常見的事。
我們在上節的例子裡已經見過這種用法了。我們建立了 iss53 和 hotfix 這兩個特性分支,在提交了若干更新後,把它們合併到主幹分支,然後刪除。該技術允許你迅速且完全的進行語境切換 — 因為你的工作分散在不同的流水線裡,每個分支裡的改變都和它的目標特性相關,瀏覽程式碼之類的事情因而變得更簡單了。你可以把作出的改變保持在特性分支中幾 分鐘,幾天甚至幾個月,等它們成熟以後再合併,而不用在乎它們建立的順序或者進度。
現在我們來看一個實際的例子。請看圖 3-20,由下往上,起先我們在 master 工作到 C1,然後開始一個新分支 iss91 嘗試修復 91 號缺陷,提交到 C6 的時候,又冒出一個解決該問題的新辦法,於是從之前 C4 的地方又分出一個分支iss91v2,幹到 C8 的時候,又回到主幹 master 中提交了 C9 和 C10,再回到 iss91v2 繼續工作,提交 C11,接著,又冒出個不太確定的想法,從 master 的最新提交 C10 處開了個新的分支dumbidea 做些試驗。
圖 3-20. 擁有多個特性分支的提交歷史。
現在,假定兩件事情:我們最終決定使用第二個解決方案,即 iss91v2 中的辦法;另外,我們把 dumbidea 分支拿給同事們看了以後,發現它竟然是個天才之作。所以接下來,我們準備拋棄原來的iss91 分支(實際上會丟棄 C5 和 C6),直接在主幹中併入另外兩個分支。最終的提交歷史將變成圖 3-21 這樣:
圖 3-21. 合併了 dumbidea 和 iss91v2 後的分支歷史。
請務必牢記這些分支全部都是本地分支,這一點很重要。當你在使用分支及合併的時候,一切都是在你自己的 Git 倉庫中進行的 — 完全不涉及與伺服器的互動。
3.5 遠端分支
遠端分支(remote branch)是對遠端倉庫中的分支的索引。它們是一些無法移動的本地分支;只有在 Git 進行網路互動時才會更新。遠端分支就像是書籤,提醒著你上次連線遠端倉庫時上面各分支的位置。
我們用 (遠端倉庫名)/(分支名) 這樣的形式表示遠端分支。比如我們想看看上次同 origin 倉庫通訊時master 的樣子,就應該檢視 origin/master 分支。如果你和同伴一起修復某個問題,但他們先推送了一個iss53 分支到遠端倉庫,雖然你可能也有一個本地的 iss53 分支,但指向伺服器上最新更新的卻應該是 origin/iss53 分支。
可能有點亂,我們不妨舉例說明。假設你們團隊有個地址為 git.ourcompany.com 的 Git 伺服器。如果你從這裡克隆,Git 會自動為你將此遠端倉庫命名為origin,並下載其中所有的資料,建立一個指向它的 master 分支的指標,在本地命名為origin/master,但你無法在本地更改其資料。接著,Git 建立一個屬於你自己的本地master 分支,始於 origin 上 master分支相同的位置,你可以就此開始工作(見圖 3-22):
圖 3-22. 一次 Git 克隆會建立你自己的本地分支 master 和遠端分支 origin/master,它們都指向 origin/master 分支的最後一次提交。
如果你在本地 master 分支做了些改動,與此同時,其他人向 git.ourcompany.com 推送了他們的更新,那麼伺服器上的master 分支就會向前推進,而於此同時,你在本地的提交歷史正朝向不同方向發展。不過只要你不和伺服器通訊,你的 origin/master 指標仍然保持原位不會移動(見圖 3-23)。
圖 3-23. 在本地工作的同時有人向遠端倉庫推送內容會讓提交歷史開始分流。
可以執行 git fetch origin 來同步遠端伺服器上的資料到本地。該命令首先找到 origin 是哪個伺服器(本例為git.ourcompany.com),從上面獲取你尚未擁有的資料,更新你本地的資料庫,然後把 origin/master 的指標移到它最新的位置上(見圖 3-24)。
圖 3-24. git fetch 命令會更新 remote 索引。
為了演示擁有多個遠端分支(在不同的遠端伺服器上)的專案是如何工作的,我們假設你還有另一個僅供你的敏捷開發小組使用的內部伺服器 git.team1.ourcompany.com。可以用第二章中提到的git remote add 命令把它加為當前專案的遠端分支之一。我們把它命名為 teamone,以便代替原始的 Git 地址(見圖 3-25)。
圖 3-25. 把另一個伺服器加為遠端倉庫
現在你可以用 git fetch teamone 來獲取小組伺服器上你還沒有的資料了。由於當前該伺服器上的內容是你 origin 伺服器上的子集,Git 不會下載任何資料,而只是簡單地建立一個名為teamone/master 的分支,指向 teamone 伺服器上 master分支所在的提交物件31b8e(見圖 3-26)。
圖 3-26. 你在本地有了一個指向 teamone 伺服器上 master 分支的索引。
推送本地分支
要想和其他人分享某個本地分支,你需要把它推送到一個你擁有寫許可權的遠端倉庫。你的本地分支不會被自動同步到你引入的遠端伺服器上,除非你明確執行推送操作。換句話說,對於無意分享的分支,你儘管保留為私人分支好了,而只推送那些協同工作要用到的特性分支。
如果你有個叫 serverfix 的分支需要和他人一起開發,可以執行 git push (遠端倉庫名) (分支名):
1 2 3 4 5 6 7 |
$ git push origin serverfix Counting objects: 20, done. Compressing objects: 100% (14/14), done. Writing objects: 100% (15/15), 1.74 KiB, done. Total 15 (delta 5), reused 0 (delta 0) To git@ github.com:schacon/simplegit.git * [new branch] serverfix -> serverfix |
這其實有點像條捷徑。Git 自動把 serverfix 分支名擴充套件為 refs/heads/serverfix:refs/heads/serverfix,意為“取出我在本地的 serverfix 分支,推送到遠端倉庫的 serverfix 分支中去”。我們將在第九章進一步介紹refs/heads/ 部分的細節,不過一般使用的時候都可以省略它。也可以執行 git push origin serverfix:serferfix 來實現相同的效果,它的意思是“上傳我本地的 serverfix 分支到遠端倉庫中去,仍舊稱它為 serverfix 分支”。通過此語法,你可以把本地分支推送到某個命名不同的遠端分支:若想把遠端分支叫作awesomebranch,可以用 git push origin serverfix:awesomebranch 來推送資料。
接下來,當你的協作者再次從伺服器上獲取資料時,他們將得到一個新的遠端分支 origin/serverfix:
1 2 3 4 5 6 7 |
$ git fetch origin remote: Counting objects: 20, done. remote: Compressing objects: 100% (14/14), done. remote: Total 15 (delta 5), reused 0 (delta 0) Unpacking objects: 100% (15/15), done. From git@ github.com:schacon/simplegit * [new branch] serverfix -> origin/serverfix |
值得注意的是,在 fetch 操作下載好新的遠端分支之後,你仍然無法在本地編輯該遠端倉庫中的分支。換句話說,在本例中,你不會有一個新的serverfix 分支,有的只是一個你無法移動的 origin/serverfix 指標。
如果要把該內容合併到當前分支,可以執行 git merge origin/serverfix。如果想要一份自己的 serverfix 來開發,可以在遠端分支的基礎上分化出一個新的分支來:
1 2 3 |
$ git checkout -b serverfix origin/serverfix Branch serverfix set up to track remote branch refs/remotes/origin/serverfix. Switched to a new branch "serverfix" |
這會切換到新建的 serverfix 本地分支,其內容同遠端分支 origin/serverfix 一致,這樣你就可以在裡面繼續開發了。
跟蹤遠端分支
從遠端分支 checkout 出來的本地分支,稱為_跟蹤分支(tracking branch)_。跟蹤分支是一種和遠端分支有直接聯絡的本地分支。在跟蹤分支裡輸入git push,Git 會自行推斷應該向哪個伺服器的哪個分支推送資料。反過來,在這些分支裡執行 git pull 會獲取所有遠端索引,並把它們的資料都合併到本地分支中來。
在克隆倉庫時,Git 通常會自動建立一個名為 master 的分支來跟蹤 origin/master。這正是git push 和 git pull 一開始就能正常工作的原因。當然,你可以隨心所欲地設定為其它跟蹤分支,比如origin 上除了 master 之外的其它分支。剛才我們已經看到了這樣的一個例子:git checkout -b [分支名] [遠端名]/[分支名]。如果你有 1.6.2 以上版本的 Git,還可以用–track 選項簡化:
1 2 3 |
$ git checkout --track origin/serverfix Branch serverfix set up to track remote branch refs/remotes/origin/serverfix. Switched to a new branch "serverfix" |
要為本地分支設定不同於遠端分支的名字,只需在前個版本的命令裡換個名字:
1 2 3 |
$ git checkout -b sf origin/serverfix Branch sf set up to track remote branch refs/remotes/origin/serverfix. Switched to a new branch "sf" |
現在你的本地分支 sf 會自動向 origin/serverfix 推送和抓取資料了。
刪除遠端分支
如果不再需要某個遠端分支了,比如搞定了某個特性並把它合併進了遠端的 master 分支(或任何其他存放穩定程式碼的地方),可以用這個非常無厘頭的語法來刪除它:git push [遠端名] :[分支名]。如果想在伺服器上刪除serverfix 分支,執行下面的命令:
1 2 3 |
$ git push origin :serverfix To git@ github.com:schacon/simplegit.git - [deleted] serverfix |
咚!伺服器上的分支沒了。你最好特別留心這一頁,因為你一定會用到那個命令,而且你很可能會忘掉它的語法。有種方便記憶這條命令的方法:記住我們不久前見過的 git push [遠端名] [本地分支]:[遠端分支] 語法,如果省略 [本地分支],那就等於是在說“在這裡提取空白然後把它變成[遠端分支]”。
3.6 分支的衍合
把一個分支整合到另一個分支的辦法有兩種:merge 和 rebase(譯註:rebase 的翻譯暫定為“衍合”,大家知道就可以了。)。在本章我們會學習什麼是衍合,如何使用衍合,為什麼衍合操作如此富有魅力,以及我們應該在什麼情況下使用衍合。
基本的衍合操作
回顧之前有關合並的一節(見圖 3-27),你會看到開發程式分叉到兩個不同分支,又各自提交了更新。
圖 3-27. 最初分叉的提交歷史。
之前介紹過,最容易的整合分支的方法是 merge 命令,它會把兩個分支最新的快照(C3 和 C4)以及二者最新的共同祖先(C2)進行三方合併,合併的結果是產生一個新的提交物件(C5)。如圖 3-28 所示:
圖 3-28. 通過合併一個分支來整合分叉了的歷史。
其實,還有另外一個選擇:你可以把在 C3 裡產生的變化補丁在 C4 的基礎上重新打一遍。在 Git 裡,這種操作叫做_衍合(rebase)_。有了 rebase 命令,就可以把在一個分支裡提交的改變移到另一個分支裡重放一遍。
在上面這個例子中,執行:
1 2 3 4 |
$ git checkout experiment $ git rebase master First, rewinding head to replay your work on top of it... Applying: added staged command |
它的原理是回到兩個分支最近的共同祖先,根據當前分支(也就是要進行衍合的分支 experiment)後續的歷次提交物件(這裡只有一個 C3),生成一系列檔案補丁,然後以基底分支(也就是主幹分支master)最後一個提交物件(C4)為新的出發點,逐個應用之前準備好的補丁檔案,最後會生成一個新的合併提交物件(C3’),從而改寫 experiment 的提交歷史,使它成為 master 分支的直接下游,如圖 3-29 所示:
圖 3-29. 把 C3 裡產生的改變到 C4 上重演一遍。
現在回到 master 分支,進行一次快進合併(見圖 3-30):
圖 3-30. master 分支的快進。
現在的 C3’ 對應的快照,其實和普通的三方合併,即上個例子中的 C5 對應的快照內容一模一樣了。雖然最後整合得到的結果沒有任何區別,但衍合能產生一個更為整潔的提交歷史。如果視察一個衍合過的分支的歷史記錄,看起來會更 清楚:彷彿所有修改都是在一根線上先後進行的,儘管實際上它們原本是同時並行發生的。
一般我們使用衍合的目的,是想要得到一個能在遠端分支上乾淨應用的補丁 — 比如某些專案你不是維護者,但想幫點忙的話,最好用衍合:先在自己的一個分支裡進行開發,當準備向主專案提交補丁的時候,根據最新的origin/master 進行一次衍合操作然後再提交,這樣維護者就不需要做任何整合工作(譯註:實際上是把解決分支補丁同最新主幹程式碼之間衝突的責任,化轉為由提交補丁的人來解決。),只需根據你提供的倉庫地址作一次快進合併,或者直接採納你提交的補丁。
請注意,合併結果中最後一次提交所指向的快照,無論是通過衍合,還是三方合併,都會得到相同的快照內容,只不過提交歷史不同罷了。衍合是按照每行的修改次序重演一遍修改,而合併是把最終結果合在一起。
有趣的衍合
衍合也可以放到其他分支進行,並不一定非得根據分化之前的分支。以圖 3-31 的歷史為例,我們為了給伺服器端程式碼新增一些功能而建立了特性分支 server,然後提交 C3 和 C4。然後又從 C3 的地方再增加一個client 分支來對客戶端程式碼進行一些相應修改,所以提交了 C8 和 C9。最後,又回到 server 分支提交了 C10。
圖 3-31. 從一個特性分支裡再分出一個特性分支的歷史。
假設在接下來的一次軟體釋出中,我們決定先把客戶端的修改併到主線中,而暫緩併入服務端軟體的修改(因為還需要進一步測試)。這個時候,我們就可以把基於 server 分支而非 master 分支的改變(即 C8 和 C9),跳過 server 直接放到master 分支中重演一遍,但這需要用 git rebase 的 –onto 選項指定新的基底分支master:
1 |
$ git rebase --onto master server client |
這好比在說:“取出 client 分支,找出 client 分支和 server 分支的共同祖先之後的變化,然後把它們在master 上重演一遍”。是不是有點複雜?不過它的結果如圖 3-32 所示,非常酷(譯註:雖然 client 裡的 C8, C9 在 C3 之後,但這僅表明時間上的先後,而非在 C3 修改的基礎上進一步改動,因為server 和 client 這兩個分支對應的程式碼應該是兩套檔案,雖然這麼說不是很嚴格,但應理解為在 C3 時間點之後,對另外的檔案所做的 C8,C9 修改,放到主幹重演。):
圖 3-32. 將特性分支上的另一個特性分支衍合到其他分支。
現在可以快進 master 分支了(見圖 3-33):
1 |
$ git checkout master $ git merge client |
圖 3-33. 快進 master 分支,使之包含 client 分支的變化。
現在我們決定把 server 分支的變化也包含進來。我們可以直接把 server 分支衍合到 master,而不用手工切換到 server分支後再執行衍合操作 — git rebase [主分支] [特性分支] 命令會先取出特性分支server,然後在主分支 master 上重演:
1 |
$ git rebase master server |
於是,server 的進度應用到 master 的基礎上,如圖 3-34 所示:
圖 3-34. 在 master 分支上衍合 server 分支。
然後就可以快進主幹分支 master 了:
1 |
$ git checkout master $ git merge server |
現在 client 和 server 分支的變化都已經整合到主幹分支來了,可以刪掉它們了。最終我們的提交歷史會變成圖 3-35 的樣子:
1 |
$ git branch -d client $ git branch -d server |
圖 3-35. 最終的提交歷史
衍合的風險
呃,奇妙的衍合也並非完美無缺,要用它得遵守一條準則:
一旦分支中的提交物件釋出到公共倉庫,就千萬不要對該分支進行衍合操作。
如果你遵循這條金科玉律,就不會出差錯。否則,人民群眾會仇恨你,你的朋友和家人也會嘲笑你,唾棄你。
在進行衍合的時候,實際上拋棄了一些現存的提交物件而創造了一些類似但不同的新的提交物件。如果你把原來分支中的提交物件釋出出去,並且其他人更新下載後在其基礎上開展工作,而稍後你又用git rebase 拋棄這些提交物件,把新的重演後的提交物件釋出出去的話,你的合作者就不得不重新合併他們的工作,這樣當你再次從他們那裡獲取內容時,提交歷史就會變得一團糟。
下面我們用一個實際例子來說明為什麼公開的衍合會帶來問題。假設你從一箇中央伺服器克隆然後在它的基礎上搞了一些開發,提交歷史類似圖 3-36 所示:
圖 3-36. 克隆一個倉庫,在其基礎上工作一番。
現在,某人在 C1 的基礎上做了些改變,併合並他自己的分支得到結果 C6,推送到中央伺服器。當你抓取併合並這些資料到你本地的開發分支中後,會得到合併結果 C7,歷史提交會變成圖 3-37 這樣:
圖 3-37. 抓取他人提交,併入自己主幹。
接下來,那個推送 C6 上來的人決定用衍合取代之前的合併操作;繼而又用 git push –force 覆蓋了伺服器上的歷史,得到 C4’。而之後當你再從伺服器上下載最新提交後,會得到:
圖 3-38. 有人推送了衍合後得到的 C4’,丟棄了你作為開發基礎的 C4 和 C6。
下載更新後需要合併,但此時衍合產生的提交物件 C4’ 的 SHA-1 校驗值和之前 C4 完全不同,所以 Git 會把它們當作新的提交物件處理,而實際上此刻你的提交歷史 C7 中早已經包含了 C4 的修改內容,於是合併操作會把 C7 和 C4’ 合併為 C8(見圖 3-39):
圖 3-39. 你把相同的內容又合併了一遍,生成一個新的提交 C8。
C8 這一步的合併是遲早會發生的,因為只有這樣你才能和其他協作者提交的內容保持同步。而在 C8 之後,你的提交歷史裡就會同時包含 C4 和 C4’,兩者有著不同的 SHA-1 校驗值,如果用git log 檢視歷史,會看到兩個提交擁有相同的作者日期與說明,令人費解。而更糟的是,當你把這樣的歷史推送到伺服器後,會再次把這些衍合後的提交引入到中央服務 器,進一步困擾其他人(譯註:這個例子中,出問題的責任方是那個釋出了 C6 後又用衍合發布 C4’ 的人,其他人會因此反饋雙重歷史到共享主幹,從而混淆大家的視聽。)。
如果把衍合當成一種在推送之前清理提交歷史的手段,而且僅僅衍合那些尚未公開的提交物件,就沒問題。如果衍合那些已經公開的提交物件,並且已經有人基於這些提交物件開展了後續開發工作的話,就會出現叫人沮喪的麻煩。
3.7 小結
讀到這裡,你應該已經學會了如何建立分支並切換到新分支,在不同分支間轉換,合併本地分支,把分支推送到共享伺服器上,使用共享分支與他人協作,以及在分享之前進行衍合。