天天看點

Git詳解之三 Git分支

Git 分支

  幾乎每一種版本控制系統都以某種形式支援分支。使用分支意味着你可以從開發主線上分離開來,然後在不影響主線的同時繼續工作。在很多版本控制系統中,這是個昂貴的過程,常常需要建立一個源代碼目錄的完整副本,對大型項目來說會花費很長時間。

  有人把 Git 的分支模型稱為“必殺技特性”,而正是因為它,将 Git 從版本控制系統家族裡區分出來。Git 有何特别之處呢?Git 的分支可謂是難以置信的輕量級,它的建立操作幾乎可以在瞬間完成,并且在不同分支間切換起來也差不多一樣快。和許多其他版本控制系統不同,Git 鼓勵在工作流程中頻繁使用分支與合并,哪怕一天之内進行許多次都沒有關系。了解分支的概念并熟練運用後,你才會意識到為什麼 Git 是一個如此強大而獨特的工具,并從此真正改變你的開發方式。

3.1  何謂分支

  為了了解 Git 分支的實作方式,我們需要回顧一下 Git 是如何儲存資料的。或許你還記得第一章的内容,Git 儲存的不是檔案差異或者變化量,而隻是一系列檔案快照。

  在 Git 中送出時,會儲存一個送出(commit)對象,該對象包含一個指向暫存内容快照的指針,包含本次送出的作者等相關附屬資訊,包含零個或多個指向該送出對 象的父對象指針:首次送出是沒有直接祖先的,普通送出有一個祖先,由兩個或多個分支合并産生的送出則有多個祖先。

  為直覺起見,我們假設在工作目錄中有三個檔案,準備将它們暫存後送出。暫存操作會對每一個檔案計算校驗和(即第一章中提到的 SHA-1 哈希字串),然後把目前版本的檔案快照儲存到 Git 倉庫中(Git 使用 blob 類型的對象存儲這些快照),并将校驗和加入暫存區域:

1 $ git add README test.rb LICENSE
2 $ git commit -m 'initial commit of my project'      

  當使用 

git commit

 建立一個送出對象前,Git 會先計算每一個子目錄(本例中就是項目根目錄)的校驗和,然後在 Git 倉庫中将這些目錄儲存為樹(tree)對象。之後 Git 建立的送出對象,除了包含相關送出資訊以外,還包含着指向這個樹對象(項目根目錄)的指針,如此它就可以在将來需要的時候,重制此次快照的内容了。

  現在,Git 倉庫中有五個對象:三個表示檔案快照内容的 blob 對象;一個記錄着目錄樹内容及其中各個檔案對應 blob 對象索引的 tree 對象;以及一個包含指向 tree 對象(根目錄)的索引和其他送出資訊中繼資料的 commit 對象。概念上來說,倉庫中的各個對象儲存的資料和互相關系看起來如圖 3-1 所示:

Git詳解之三 Git分支

圖 3-1. 單個送出對象在倉庫中的資料結構

  作些修改後再次送出,那麼這次的送出對象會包含一個指向上次送出對象的指針(譯注:即下圖中的 parent 對象)。兩次送出後,倉庫曆史會變成圖 3-2 的樣子:

Git詳解之三 Git分支

圖 3-2. 多個送出對象之間的連結關系

                                                  

  現在來談分支。Git 中的分支,其實本質上僅僅是個指向 commit 對象的可變指針。Git 會使用 master 作為分支的預設名字。在若幹次送出後,你其實已經有了一個指向最後一次送出對象的 master 分支,它在每次送出的時候都會自動向前移動。

Git詳解之三 Git分支

圖 3-3. 分支其實就是從某個送出對象往回看的曆史

                                                

  那麼,Git 又是如何建立一個新的分支的呢?答案很簡單,建立一個新的分支指針。比如建立一個 testing 分支,可以使用 

git branch

 指令:

  $ git branch testing           

  這會在目前 commit 對象上建立一個分支指針(見圖 3-4)。

Git詳解之三 Git分支

圖 3-4. 多個分支指向送出資料的曆史

  那麼,Git 是如何知道你目前在哪個分支上工作的呢?其實答案也很簡單,它儲存着一個名為 HEAD 的特别指針。請注意它和你熟知的許多其他版本控制系統(比如 Subversion 或 CVS)裡的 HEAD 概念大不相同。在 Git 中,它是一個指向你正在工作中的本地分支的指針(譯注:将 HEAD 想象為目前分支的别名。)。運作

git branch

 指令,僅僅是建立了一個新的分支,但不會自動切換到這個分支中去,是以在這個例子中,我們依然還在 master 分支裡工作(參考圖 3-5)。

Git詳解之三 Git分支

圖 3-5. HEAD 指向目前所在的分支

  要切換到其他分支,可以執行 

git checkout

 指令。我們現在轉換到建立的 testing 分支:

  $ git checkout testing           

  這樣 HEAD 就指向了 testing 分支(見圖3-6)。

Git詳解之三 Git分支

圖 3-6. HEAD 在你轉換分支時指向新的分支

                                                 

  這樣的實作方式會給我們帶來什麼好處呢?好吧,現在不妨再送出一次:

  $ vim test.rb
  $ git commit -a -m 'made a change'           

  圖 3-7 展示了送出後的結果。

Git詳解之三 Git分支

圖 3-7. 每次送出後 HEAD 随着分支一起向前移動

  非常有趣,現在 testing 分支向前移動了一格,而 master 分支仍然指向原先 

git checkout

 時所在的 commit 對象。現在我們回到 master 分支看看:

  $ git checkout master           

  圖 3-8 顯示了結果。

Git詳解之三 Git分支

圖 3-8. HEAD 在一次 checkout 之後移動到了另一個分支

                                              

  這條指令做了兩件事。它把 HEAD 指針移回到 master 分支,并把工作目錄中的檔案換成了 master 分支所指向的快照内容。也就是說,現在開始所做的改動,将始于本項目中一個較老的版本。它的主要作用是将 testing 分支裡作出的修改暫時取消,這樣你就可以向另一個方向進行開發。

  我們作些修改後再次送出:

  $ vim test.rb
  $ git commit -a -m 'made other changes'           

  現在我們的項目送出曆史産生了分叉(如圖 3-9 所示),因為剛才我們建立了一個分支,轉換到其中進行了一些工作,然後又回到原來的主分支進行了另外一些工作。這些改變分别孤立在不同的分支裡:我們可以 在不同分支裡反複切換,并在時機成熟時把它們合并到一起。而所有這些工作,僅僅需要

branch

 和 

checkout

 這兩條指令就可以完成。

Git詳解之三 Git分支

圖 3-9. 不同流向的分支曆史

                                                        

  由于 Git 中的分支實際上僅是一個包含所指對象校驗和(40 個字元長度 SHA-1 字串)的檔案,是以建立和銷毀一個分支就變得非常廉價。說白了,建立一個分支就是向一個檔案寫入 41 個位元組(外加一個換行符)那麼簡單,當然也就很快了。

  這和大多數版本控制系統形成了鮮明對比,它們管理分支大多采取備份所有項目檔案到特定目錄的方式,是以根據項目檔案數量和大小不同,可能花費的時間 也會有相當大的差别,快則幾秒,慢則數分鐘。而 Git 的實作與項目複雜度無關,它永遠可以在幾毫秒的時間内完成分支的建立和切換。同時,因為每次送出時都記錄了祖先資訊(譯注:即

parent

 對象),将來要合并分支時,尋找恰當的合并基礎(譯注:即共同祖先)的工作其實已經自然而然地擺在那裡了,是以實作起來非常容易。Git 鼓勵開發者頻繁使用分支,正是因為有着這些特性作保障。

  接下來看看,我們為什麼應該頻繁使用分支。

3.2  分支的建立與合并

  現在讓我們來看一個簡單的分支與合并的例子,實際工作中大體也會用到這樣的工作流程:

  1. 開發某個網站。

  2. 為實作某個新的需求,建立一個分支。

  3. 在這個分支上開展工作。

  假設此時,你突然接到一個電話說有個很嚴重的問題需要緊急修補,那麼可以按照下面的方式處理:

   1. 傳回到原先已經釋出到生産伺服器上的分支。

   2. 為這次緊急修補建立一個新分支,并在其中修複問題。

   3. 通過測試後,回到生産伺服器所在的分支,将修補分支合并進來,然後再推送到生産伺服器上。

   4. 切換到之前實作新需求的分支,繼續工作。

分支的建立與切換

  首先,我們假設你正在項目中愉快地工作,并且已經送出了幾次更新(見圖 3-10)。

Git詳解之三 Git分支

  圖 3-10. 一個簡短的送出曆史

  現在,你決定要修補問題追蹤系統上的 #53 問題。順帶說明下,Git 并不同任何特定的問題追蹤系統打交道。這裡為了說明要解決的問題,才把建立的分支取名為 iss53。要建立并切換到該分支,運作

git checkout

 并加上 

-b

 參數:

  $ git checkout -b iss53
  Switched to a new branch "iss53"           

  這相當于執行下面這兩條指令:

  $ git branch iss53
  $ git checkout iss53           

   圖 3-11 示意該指令的執行結果。

Git詳解之三 Git分支

圖 3-11. 建立了一個新分支的指針

                                      

接着你開始嘗試修複問題,在送出了若幹次更新後,

iss53

 分支的指針也會随着向前推進,因為它就是目前分支(換句話說,目前的 

HEAD

 指針正指向 

iss53

,見圖 3-12):

$ vim index.html
$ git commit -a -m 'added a new footer [issue 53]'           
Git詳解之三 Git分支

圖 3-12. iss53 分支随工作進展向前推進

  現在你就接到了那個網站問題的緊急電話,需要馬上修補。有了 Git ,我們就不需要同時釋出這個更新檔和 

iss53

 裡作出的修改,也不需要在建立和釋出該更新檔到伺服器之前花費大力氣來複原這些修改。唯一需要的僅僅是切換回

master

 分支。

  不過在此之前,留心你的暫存區或者工作目錄裡,那些還沒有送出的修改,它會和你即将檢出的分支産生沖突進而阻止 Git 為你切換分支。切換分支的時候最好保持一個清潔的工作區域。稍後會介紹幾個繞過這種問題的辦法(分别叫做 stashing 和 commit amending)。目前已經送出了所有的修改,是以接下來可以正常轉換到

master

 分支:

  $ git checkout master
  Switched to branch "master"           

  此時工作目錄中的内容和你在解決問題 #53 之前一模一樣,你可以集中精力進行緊急修補。這一點值得牢記:Git 會把工作目錄的内容恢複為檢出某分支時它所指向的那個送出對象的快照。它會自動添加、删除和修改檔案以確定目錄的内容和你當時送出時完全一樣。

  接下來,你得進行緊急修補。我們建立一個緊急修補分支 

hotfix

 來開展工作,直到搞定(見圖 3-13):

$ 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(-)      
Git詳解之三 Git分支

圖 3-13. hotfix 分支是從 master 分支所在點分化出來的

  有必要作些測試,確定修補是成功的,然後回到 

master

 分支并把它合并進來,然後釋出到生産伺服器。用 

git merge

 指令來進行合并:

$ 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)。

Git詳解之三 Git分支

圖 3-14. 合并之後,master 分支和 hotfix 分支指向同一位置。

  在那個超級重要的修補釋出以後,你想要回到被打擾之前的工作。由于目前 

hotfix

 分支和 

master

 都指向相同的送出對象,是以

hotfix

 已經完成了曆史使命,可以删掉了。使用 

git branch

 的 

-d

 選項執行删除操作:

  $ git branch -d hotfix
  Deleted branch hotfix (3a0874c).           

  現在回到之前未完成的 #53 問題修複分支上繼續工作(圖 3-15):

$ 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(-)      
Git詳解之三 Git分支

圖 3-15. iss53 分支可以不受影響繼續推進。

  不用擔心之前 

hotfix

 分支的修改内容尚未包含到 

iss53

 中來。如果确實需要納入此次修補,可以用

git merge master

 把 master 分支合并到 

iss53

;或者等 

iss53

 完成之後,再将

iss53

分支中的更新并入 

master

分支的合并

  在問題 #53 相關的工作完成之後,可以合并回 

master

 分支。實際操作同前面合并 

hotfix

 分支差不多,隻需回到

master

 分支,運作 

git merge

 指令指定要合并進來的分支:

$ 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 用于合并的三個送出對象:

Git詳解之三 Git分支

圖 3-16. Git 為分支合并自動識别出最佳的同源合并點。

  這次,Git 沒有簡單地把分支指針右移,而是對三方合并後的結果重新做一個新的快照,并自動建立一個指向它的送出對象(C6)(見圖 3-17)。這個送出對象比較特殊,它有兩個祖先(C4 和 C5)。

  值得一提的是 Git 可以自己裁決哪個共同祖先才是最佳合并基礎;這和 CVS 或 Subversion(1.5 以後的版本)不同,它們需要開發者手工指定合并基礎。是以此特性讓 Git 的合并操作比其他系統都要簡單不少。

Git詳解之三 Git分支

圖 3-17. Git 自動建立了一個包含了合并結果的送出對象。

  既然之前的工作成果已經合并到 

master

 了,那麼 

iss53

 也就沒用了。你可以就此删除它,并在問題追蹤系統裡關閉該問題。

  $ git branch -d iss53

           

Checkout 曆史版本

從某個曆史版本建立新的分支

在 Git 中從目前分支建立并檢出新分支的指令是

git checkout -b name-of-new-branch      

這個指令實際上是

git checkout -b name-of-new-branch current-branch      

的簡寫形式。也就是說,當我們不指定 checkout 起點時,Git 預設從目前活動分支開始建立新的分支。

Git 的每個送出都有一個 SHA1 散列值(Hash 值)作為 ID。我們可以在 

checkout

 指令中使用這些 ID 作為起點。比如:

git checkout -b name-of-new-branch 169d2dc      

這樣,Git 的活動分支會切換到 

name-of-new-branch

 這個分支上,而它的内容與 

169d2dc

 這個分支一緻。

注意:SHA1 的散列值有 40 個字母,相當長。是以 Git 允許我們在不引起歧義的情況下,使用散列值的前幾位作為縮寫。

提示:你也可以用 

git branch name-of-new-branch 169d2dc

 來建立一個曆史分支,而不切換到該分支。

将某個曆史版本 checkout 到工作區

首先說明,這樣做會産生一個分離的 HEAD 指針,是以個人不推薦這麼做。

如果我們工作在 

master

 分支上,希望 checkout 到 

dev

 分支上,我們會這麼做:

git checkout dev      

這裡 

dev

 實際上是一個指針的别名,其本質也是一個 SHA1 散列值。是以,我們很自然地可以用

git checkout <sha1-of-a-commit>      

将某個曆史版本 checkout 到工作區。

将某個檔案的曆史版本 checkout 到工作區

大多數時候,我們可能隻需要對某一個檔案做細小的修補,是以隻 checkout 該檔案就行了,并不需要操作整個 commit 或分支。

上一節我們介紹了如何将某個曆史版本完整地 checkout 到工作區。實際上,我們隻需要在上一節的指令之後加上需要 checkout 的檔案即可。

git checkout <sha1-of-a-commit> </path/to/your/file>      

當然,有時候你需要将某個檔案的曆史版本 checkout 出來,并以一個新的名字儲存。這時候可以這麼做:

遇到沖突時的分支合并

  有時候合并操作并不會如此順利。如果在不同的分支中都修改了同一個檔案的同一部分,Git 就無法幹淨地把兩者合到一起(譯注:邏輯上說,這種問題隻能由人來裁決。)。如果你在解決問題 #53 的過程中修改了

hotfix

 中修改的部分,将得到類似下面的結果:

$ 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

 查閱:

[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:index.html
contact : [email protected]
=======
please contact us at [email protected]
>>>>>>> iss53:index.html      

  可以看到 

=======

 隔開的上半部分,是 

HEAD

(即 

master

 分支,在運作

merge

 指令時所切換到的分支)中的内容,下半部分是在 

iss53

 分支中的内容。解決沖突的辦法無非是二者選其一或者由你親自整合到一起。比如你可以通過把這段内容替換為下面這樣來解決:

  please contact us at [email protected]

  這個解決方案各采納了兩個分支中的一部分内容,而且我還删除了 

<<<<<<<

=======

>>>>>>>

 這些行。在解決了所有檔案裡的所有沖突後,運作 

git add

 将把它們标記為已解決狀态(譯注:實際上就是來一次快照儲存到暫存區域。)。因為一旦暫存,就表示沖突已經解決。如果你想用一個有圖形界面的工具來解決這些問題,不妨運作

git mergetool

,它會調用一個可視化的合并工具并引導你解決所有沖突:

$ 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

 來确認所有沖突都已解決:

$ git status
# On branch master
# Changes to be committed:
#   (use "git reset HEAD 
    

     
     ..." to unstage)
#
#    modified:   index.html
#
    
          

  如果覺得滿意了,并且确認所有沖突都已解決,也就是進入了暫存區,就可以用 

git commit

 來完成這次合并送出。送出的記錄差不多是這樣:

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

 指令不僅僅能建立和删除分支,如果不加任何參數,它會給出目前所有分支的清單:

$ git branch
  iss53
* master
  testing      

注意看 

master

 分支前的 

*

 字元:它表示目前所在的分支。也就是說,如果現在送出更新,

master

 分支将随着開發進度前移。若要檢視各個分支最後一個送出對象的資訊,運作

git branch -v

$ 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

 檢視哪些分支已被并入目前分支(譯注:也就是說哪些分支是目前分支的直接上遊。):

$ git branch --merged
  iss53
* master      

之前我們已經合并了 

iss53

,是以在這裡會看到它。一般來說,清單中沒有 

*

 的分支通常都可以用 

git branch -d

 來删掉。原因很簡單,既然已經把它們所包含的工作整合到了其他分支,删掉也不會損失什麼。

另外可以用 

git branch --no-merged

 檢視尚未合并的工作:

$ git branch --no-merged
  testing           

它會顯示還未合并進來的分支。由于這些分支中還包含着尚未合并進來的工作成果,是以簡單地用 

git branch -d

 删除該分支會提示錯誤,因為那樣做會丢失資料:

$ 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)。

Git詳解之三 Git分支

圖 3-18. 穩定分支總是比較老舊。

或者把它們想象成工作流水線,或許更好了解一些,經過測試的送出對象集合被遴選到更穩定的流水線(見圖 3-19)。

Git詳解之三 Git分支

圖 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

 做些試驗。

Git詳解之三 Git分支

圖 3-20. 擁有多個特性分支的送出曆史。

  現在,假定兩件事情:我們最終決定使用第二個解決方案,即 

iss91v2

 中的辦法;另外,我們把 

dumbidea

 分支拿給同僚們看了以後,發現它竟然是個天才之作。是以接下來,我們準備抛棄原來的

iss91

 分支(實際上會丢棄 C5 和 C6),直接在主幹中并入另外兩個分支。最終的送出曆史将變成圖 3-21 這樣:

Git詳解之三 Git分支

圖 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):

Git詳解之三 Git分支

圖 3-22. 一次 Git 克隆會建立你自己的本地分支 master 和遠端分支 origin/master,它們都指向 origin/master 分支的最後一次送出。

                            

  如果你在本地 

master

 分支做了些改動,與此同時,其他人向 

git.ourcompany.com

 推送了他們的更新,那麼伺服器上的

master

 分支就會向前推進,而于此同時,你在本地的送出曆史正朝向不同方向發展。不過隻要你不和伺服器通訊,你的 

origin/master

 指針仍然保持原位不會移動(見圖 3-23)。

Git詳解之三 Git分支

圖 3-23. 在本地工作的同時有人向遠端倉庫推送内容會讓送出曆史開始分流。

可以運作 

git fetch origin

 來同步遠端伺服器上的資料到本地。該指令首先找到 

origin

 是哪個伺服器(本例為

git.ourcompany.com

),從上面擷取你尚未擁有的資料,更新你本地的資料庫,然後把 

origin/master

 的指針移到它最新的位置上(見圖 3-24)。

Git詳解之三 Git分支

圖 3-24. git fetch 指令會更新 remote 索引。

  為了示範擁有多個遠端分支(在不同的遠端伺服器上)的項目是如何工作的,我們假設你還有另一個僅供你的靈活開發小組使用的内部伺服器 

git.team1.ourcompany.com

。可以用第二章中提到的

git remote add

 指令把它加為目前項目的遠端分支之一。我們把它命名為 

teamone

,以便代替原始的 Git 位址(見圖 3-25)。

Git詳解之三 Git分支

圖 3-25. 把另一個伺服器加為遠端倉庫

  現在你可以用 

git fetch teamone

 來擷取小組伺服器上你還沒有的資料了。由于目前該伺服器上的内容是你 

origin

 伺服器上的子集,Git 不會下載下傳任何資料,而隻是簡單地建立一個名為

teamone/master

 的分支,指向 

teamone

 伺服器上 

master

 分支所在的送出對象

31b8e

(見圖 3-26)。

Git詳解之三 Git分支

圖 3-26. 你在本地有了一個指向 teamone 伺服器上 master 分支的索引。

推送本地分支

  要想和其他人分享某個本地分支,你需要把它推送到一個你擁有寫權限的遠端倉庫。你的本地分支不會被自動同步到你引入的遠端伺服器上,除非你明确執行推送操作。換句話說,對于無意分享的分支,你盡管保留為私人分支好了,而隻推送那些協同工作要用到的特性分支。

如果你有個叫 

serverfix

 的分支需要和他人一起開發,可以運作 

git push (遠端倉庫名) (分支名)

$ 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 [email protected]: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

$ 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 [email protected]:schacon/simplegit
 * [new branch]      serverfix    -> origin/serverfix      

  值得注意的是,在 

fetch

 操作下載下傳好新的遠端分支之後,你仍然無法在本地編輯該遠端倉庫中的分支。換句話說,在本例中,你不會有一個新的

serverfix

 分支,有的隻是一個你無法移動的 

origin/serverfix

 指針。

  如果要把該内容合并到目前分支,可以運作 

git merge origin/serverfix

。如果想要一份自己的 

serverfix

 來開發,可以在遠端分支的基礎上分化出一個新的分支來:

$ 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

 選項簡化:

$ git checkout --track origin/serverfix
Branch serverfix set up to track remote branch refs/remotes/origin/serverfix.
Switched to a new branch "serverfix"      

  要為本地分支設定不同于遠端分支的名字,隻需在前個版本的指令裡換個名字:

$ 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

 分支,運作下面的指令:

$ git push origin :serverfix
To [email protected]:schacon/simplegit.git
 - [deleted]         serverfix      

咚!伺服器上的分支沒了。你最好特别留心這一頁,因為你一定會用到那個指令,而且你很可能會忘掉它的文法。有種友善記憶這條指令的方法:記住我們不久前見過的 

git push [遠端名] [本地分支]:[遠端分支]

 文法,如果省略 

[本地分支]

,那就等于是在說“在這裡提取空白然後把它變成

[遠端分支]

”。

3.6  分支的衍合

  把一個分支整合到另一個分支的辦法有兩種:

merge

rebase

(譯注:

rebase

 的翻譯暫定為“衍合”,大家知道就可以了。)。在本章我們會學習什麼是衍合,如何使用衍合,為什麼衍合操作如此富有魅力,以及我們應該在什麼情況下使用衍合。

基本的衍合操作

  請回顧之前有關合并的一節(見圖 3-27),你會看到開發程序分叉到兩個不同分支,又各自送出了更新。

Git詳解之三 Git分支

圖 3-27. 最初分叉的送出曆史。

                                                    

  之前介紹過,最容易的整合分支的方法是 

merge

 指令,它會把兩個分支最新的快照(C3 和 C4)以及二者最新的共同祖先(C2)進行三方合并,合并的結果是産生一個新的送出對象(C5)。如圖 3-28 所示:

Git詳解之三 Git分支

圖 3-28. 通過合并一個分支來整合分叉了的曆史。

  其實,還有另外一個選擇:你可以把在 C3 裡産生的變化更新檔在 C4 的基礎上重新打一遍。在 Git 裡,這種操作叫做_衍合(rebase)_。有了 

rebase

 指令,就可以把在一個分支裡送出的改變移到另一個分支裡重放一遍。

  在上面這個例子中,運作:

$ 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 所示:

Git詳解之三 Git分支

圖 3-29. 把 C3 裡産生的改變到 C4 上重演一遍。

現在回到 

master

 分支,進行一次快進合并(見圖 3-30):

Git詳解之三 Git分支

圖 3-30. master 分支的快進。

  現在的 C3’ 對應的快照,其實和普通的三方合并,即上個例子中的 C5 對應的快照内容一模一樣了。雖然最後整合得到的結果沒有任何差別,但衍合能産生一個更為整潔的送出曆史。如果視察一個衍合過的分支的曆史記錄,看起來會更 清楚:仿佛所有修改都是在一根線上先後進行的,盡管實際上它們原本是同時并行發生的。

  一般我們使用衍合的目的,是想要得到一個能在遠端分支上幹淨應用的更新檔 — 比如某些項目你不是維護者,但想幫點忙的話,最好用衍合:先在自己的一個分支裡進行開發,當準備向主項目送出更新檔的時候,根據最新的

origin/master

 進行一次衍合操作然後再送出,這樣維護者就不需要做任何整合工作(譯注:實際上是把解決分支更新檔同最新主幹代碼之間沖突的責任,化轉為由送出更新檔的人來解決。),隻需根據你提供的倉庫位址作一次快進合并,或者直接采納你送出的更新檔。

  請注意,合并結果中最後一次送出所指向的快照,無論是通過衍合,還是三方合并,都會得到相同的快照内容,隻不過送出曆史不同罷了。衍合是按照每行的修改次序重演一遍修改,而合并是把最終結果合在一起。

有趣的衍合

  衍合也可以放到其他分支進行,并不一定非得根據分化之前的分支。以圖 3-31 的曆史為例,我們為了給伺服器端代碼添加一些功能而建立了特性分支 

server

,然後送出 C3 和 C4。然後又從 C3 的地方再增加一個

client

 分支來對用戶端代碼進行一些相應修改,是以送出了 C8 和 C9。最後,又回到 

server

 分支送出了 C10。

Git詳解之三 Git分支

圖 3-31. 從一個特性分支裡再分出一個特性分支的曆史。

                                             

  假設在接下來的一次軟體釋出中,我們決定先把用戶端的修改并到主線中,而暫緩并入服務端軟體的修改(因為還需要進一步測試)。這個時候,我們就可以把基于 

server

 分支而非 

master

 分支的改變(即 C8 和 C9),跳過 

server

 直接放到

master

 分支中重演一遍,但這需要用 

git rebase

--onto

 選項指定新的基底分支

master

$ git rebase --onto master server client           

  這好比在說:“取出 

client

 分支,找出 

client

server

 分支的共同祖先之後的變化,然後把它們在

master

 上重演一遍”。是不是有點複雜?不過它的結果如圖 3-32 所示,非常酷(譯注:雖然 

client

 裡的 C8, C9 在 C3 之後,但這僅表明時間上的先後,而非在 C3 修改的基礎上進一步改動,因為

server

client

 這兩個分支對應的代碼應該是兩套檔案,雖然這麼說不是很嚴格,但應了解為在 C3 時間點之後,對另外的檔案所做的 C8,C9 修改,放到主幹重演。):

Git詳解之三 Git分支

圖 3-32. 将特性分支上的另一個特性分支衍合到其他分支。

                                            

現在可以快進 

master

 分支了(見圖 3-33):

$ git checkout master
$ git merge client           
Git詳解之三 Git分支

圖 3-33. 快進 master 分支,使之包含 client 分支的變化。

  現在我們決定把 

server

 分支的變化也包含進來。我們可以直接把 

server

 分支衍合到 

master

,而不用手工切換到 

server

 分支後再執行衍合操作 — 

git rebase [主分支] [特性分支]

指令會先取出特性分支

server

,然後在主分支 

master

 上重演:

$ git rebase master server           

于是,

server

 的進度應用到 

master

 的基礎上,如圖 3-34 所示:

Git詳解之三 Git分支

圖 3-34. 在 master 分支上衍合 server 分支。

然後就可以快進主幹分支 

master

 了:

$ git checkout master
$ git merge server           

現在 

client

server

 分支的變化都已經內建到主幹分支來了,可以删掉它們了。最終我們的送出曆史會變成圖 3-35 的樣子:

$ git branch -d client
$ git branch -d server           
Git詳解之三 Git分支

圖 3-35. 最終的送出曆史

                                                      

衍合的風險

  呃,奇妙的衍合也并非完美無缺,要用它得遵守一條準則:

  一旦分支中的送出對象釋出到公共倉庫,就千萬不要對該分支進行衍合操作。

  如果你遵循這條金科玉律,就不會出差錯。否則,人民群衆會仇恨你,你的朋友和家人也會嘲笑你,唾棄你。

  在進行衍合的時候,實際上抛棄了一些現存的送出對象而創造了一些類似但不同的新的送出對象。如果你把原來分支中的送出對象釋出出去,并且其他人更新下載下傳後在其基礎上開展工作,而稍後你又用

git rebase

 抛棄這些送出對象,把新的重演後的送出對象釋出出去的話,你的合作者就不得不重新合并他們的工作,這樣當你再次從他們那裡擷取内容時,送出曆史就會變得一團糟。

  下面我們用一個實際例子來說明為什麼公開的衍合會帶來問題。假設你從一個中央伺服器克隆然後在它的基礎上搞了一些開發,送出曆史類似圖 3-36 所示:

Git詳解之三 Git分支

圖 3-36. 克隆一個倉庫,在其基礎上工作一番。

現在,某人在 C1 的基礎上做了些改變,并合并他自己的分支得到結果 C6,推送到中央伺服器。當你抓取并合并這些資料到你本地的開發分支中後,會得到合并結果 C7,曆史送出會變成圖 3-37 這樣:

Git詳解之三 Git分支

圖 3-37. 抓取他人送出,并入自己主幹。

接下來,那個推送 C6 上來的人決定用衍合取代之前的合并操作;繼而又用 

git push --force

 覆寫了伺服器上的曆史,得到 C4’。而之後當你再從伺服器上下載下傳最新送出後,會得到:

Git詳解之三 Git分支

圖 3-38. 有人推送了衍合後得到的 C4’,丢棄了你作為開發基礎的 C4 和 C6。

                                           

下載下傳更新後需要合并,但此時衍合産生的送出對象 C4’ 的 SHA-1 校驗值和之前 C4 完全不同,是以 Git 會把它們當作新的送出對象處理,而實際上此刻你的送出曆史 C7 中早已經包含了 C4 的修改内容,于是合并操作會把 C7 和 C4’ 合并為 C8(見圖 3-39):

Git詳解之三 Git分支

圖 3-39. 你把相同的内容又合并了一遍,生成一個新的送出 C8。

  C8 這一步的合并是遲早會發生的,因為隻有這樣你才能和其他協作者送出的内容保持同步。而在 C8 之後,你的送出曆史裡就會同時包含 C4 和 C4’,兩者有着不同的 SHA-1 校驗值,如果用

git log

 檢視曆史,會看到兩個送出擁有相同的作者日期與說明,令人費解。而更糟的是,當你把這樣的曆史推送到伺服器後,會再次把這些衍合後的送出引入到中央服務 器,進一步困擾其他人(譯注:這個例子中,出問題的責任方是那個釋出了 C6 後又用衍合釋出 C4’ 的人,其他人會是以回報雙重曆史到共享主幹,進而混淆大家的視聽。)。

  如果把衍合當成一種在推送之前清理送出曆史的手段,而且僅僅衍合那些尚未公開的送出對象,就沒問題。如果衍合那些已經公開的送出對象,并且已經有人基于這些送出對象開展了後續開發工作的話,就會出現叫人沮喪的麻煩。

小結

  讀到這裡,你應該已經學會了如何建立分支并切換到新分支,在不同分支間轉換,合并本地分支,把分支推送到共享伺服器上,使用共享分支與他人協作,以及在分享之前進行衍合。

下一篇: LVS