第 7 章:分支使用與管理
簡述分支
為了理解 Git 分支(branch)的使用方式,我們 需要回顧一下 Git 是如何保存資料的。Git 保存的不是變更集或者差異內容,而是一系列快照。
當你製造一個提交(commit)時,Git 會儲存一個提交物件,該物件內容包含一個指標,用來代表已預存的快照內容; 這個物件內容還包含「作者名字和電子郵件」、「你輸入的訊息內容」、「指向前一個提交的指標(該提交的親代提交)」:沒有親代(parent)提交表示它是初始的第一個提交,一般情況下只有一個親代提交,超過一個親代提交表示它是從二個以上的分支合併而來的。
為了具體說明,讓我們假設你有一個目錄包含了三個檔案,你預存(stage)並提交了它們; 檔案預存操作會對每一個檔案內容(譯註:請注意,只有檔案「內容」)計算雜湊值(即 [ch01-introduction] 中提到的 SHA-1 雜湊值),然後把那個檔案內容版本保存到 Git 版本庫中(Git 把它們視為 blob 類型的物件),再將這個雜湊值寫入預存區(staging area):
git add README test.rb LICENSE
git commit -m 'The initial commit of my project'
當使用 git commit
建立一個提交時,Git 會先計算每一個子目錄(本例中則只有專案根目錄)的雜湊值,然後在 Git 版本庫中將這些目錄記錄為樹(tree)物件; 之後 Git 建立提交物件,它除了包含相關提交資訊以外,還包含著指向專案根目錄的樹物件指標,如此它就可以在需要的時候重建此次快照內容。
你的 Git 版本庫現在有五個物件:三個 blob 物件用來儲存檔案內容、一個樹物件用來列出目錄的內容並紀錄各個檔案所對應的 blob 物件、一個提交用來記錄根目錄的樹物件和其他提交資訊。
)
如果你做一些修改並再次提交,這次的提交會再包含一個指向上次提交的指標(譯注:即下圖中的 parent 欄位)。
)
Git 分支其實只是一個指向某提交的可移動輕量級指標, Git 預設分支名稱是 master
, 隨著不斷地製作提交,master
分支會為你一直指向最後一個提交, 它在每次提交的時候都會自動向前移動。
「master」在 Git 中並不是一個特殊的分支, 它和其它分支並無分別, 之所以幾乎每個版本庫裡都會有這個分支的原因是 git init
命令的預設行為會產生它,而大部分的人就這麼直接使用它。
)
建立一個新的分支
建立一個新分支會發生什麼事呢? 答案很簡單,建立一個新的、可移動的指標; 比如新建一個 testing 分支, 可以使用 git branch
命令:
git branch testing
這會在目前提交上新建一個指標。
)
Git 如何知道你目前在哪個分支上工作的呢? 其實它保存了一個名為 HEAD
的特別指標; 請注意它和你可能慣用的其他 VCSs 裡的 HEAD
概念大不相同,比如 Subversion 或 CVS; 在 Git 中,它就是一個指向你正在工作中的本地分支的指標(譯注:HEAD 等於「目前的」), 所以在這個例子中,你仍然在 master
分支上工作; 執行 git branch
命令,只是「建立」一個新的分支——它並不會切換到這個分支。
)
你可以很輕鬆地看到分支指標指向何處,只需透過一個簡單的 git log
命令, 加上 --decorate
選項。
git log --oneline --decorate
f30ab (HEAD -> master, testing) add feature #32 - ability to add new formats to the central interface
34ac2 Fixed bug #1328 - stack overflow under certain conditions
98ca9 The initial commit of my project
你可以看到「master」和「testing」分支就顯示在 f30ab
提交旁邊。
在分支之間切換
要切換到一個已經存在的分支,你可以執行 git checkout
命令, 讓我們切換到新的 testing
分支:
git checkout testing
這會移動 HEAD
並指向 testing
分支。
)
這樣做有什麼意義呢? 好吧!讓我們再提交一次:
vim test.rb
git commit -a -m 'made a change'
)
圖表 15. 當再次提交時,被 HEAD 指向的分支會往前走
非常有趣,現在 testing
分支向前移動了,而 master
分支仍然指向當初在執行 git checkout
時所在的提交, 讓我們切回 master
分支看看:
git checkout master
)
這條命令做了兩件事, 它把 HEAD 指標移回去並指向 master
分支,然後把工作目錄中的檔案換成 master
分支所指向的快照內容; 也就是說,現在開始所做的改動,將基於專案中較舊的版本,然後與其它提交歷史分離開來; 它實際上是取消你在 testing
分支裡所做的 修改,這樣你就可以往不同方向前進。
切換分支會修改工作目錄裡的檔案 重要的是要注意:當你在 Git 中切換分支時,工作目錄內的檔案將會被修改; 如果切換到舊分支,你的工作目錄會回復到看起來就像當初你最後一次在這個分支提交時的樣子。 如果 Git 無法很乾淨地切換過去,它就不會讓你切換過去。
讓我們做一些修改並再次提交:
vim test.rb
git commit -a -m 'made other changes'
現在你的專案歷史開始分離了(詳見 分離的歷史); 你建立並切換到新分支,在上面進行了一些工作,然後切換回到主分支進行了另外一些工作, 雙方的改變分別隔離在不同的分支裡:你可以在不同分支裡反覆切換,並在時機成熟時把它們合併到一起; 而所有這些工作只需要簡單的 branch
、checkout
、commit
命令。
)
你一樣可以從 git log 中輕鬆地看出這件事, 執行 git log --oneline --decorate --graph --all,它會印出你的提交歷史,顯示你的分支指標在哪裡,以及歷史如何被分離開來。
$ git log --oneline --decorate --graph --all
* c2b9e (HEAD, master) made other changes
| * 87ab2 (testing) made a change
|/
* f30ab add feature #32 - ability to add new formats to the
* 34ac2 fixed bug #1328 - stack overflow under certain conditions
* 98ca9 initial commit of my project
由於 Git 分支實際上只是一個檔案,該檔案內容是這個分支指向的提交的雜湊值(40 個字元長度的 SHA-1 字串),所以建立和銷毀一個分支就變得非常廉價; 新建一個分支就是向一個檔寫入 41 個位元組(40 個字元外加一個換行符號)那樣地簡單和快速。
這樣的分支功能和大多數舊 VCS 的分支功能形成了鮮明的對比,有些分支功能甚至需要複製專案中全部的檔案到另一個資料夾, 而根據專案檔案數量和大小的不同,可能花費的時間快則幾秒,慢則數分鐘;而在 Git 中幾乎都在瞬間完成。 還有,因為每次提交時都記錄了親代資訊,將來要合併分支時,它通常會幫我們自動並輕鬆地找到適當的合併基礎; 這樣子的特性在無形間鼓勵了開發者頻繁地建立和使用分支。
讓我們來瞧一瞧為什麼你應該要這麼做。
分支和合併的基本用法
讓我們來看一個你在現實生活中,有可能會用到的分支(branch)與合併(merge)工作流程的簡單範例, 你做了以下動作:
- 開發一個網站。
- 建立一個分支以實現一個新故事。
- 在這個分支上進行開發。
此時你接到一個電話,有個很危急的問題需要緊急修正(hotfix), 你可以按照下面的方式處理:
- 切換到發佈產品用的分支。
- 在同一個提交上建立一個新分支,在這個分支上修正問題。
- 通過測試後,切回發佈產品用的分支,將修正用的分支合併進來,然後再推送(push)出去以發佈產品。
- 切換到之前實現新需求的分支以繼續工作。
分支的基本用法
首先,我們假設你正在開發你的專案,並且已經有一些提交(commit)了。
)
無論你的公司使用的議題追蹤系統(issue-tracking system)是哪一套,你決定要修正其中的議題 #53; 要同時新建並切換到新分支,你可以在執行 git checkout
時加上 -b
選項:
git checkout -b iss53
Switched to a new branch "iss53"
它相當於下面這兩條命令:
git branch iss53
git checkout iss53
)
你開始開發網站,並做了一些提交; 因為你檢出(checkout)了這個分支(也就是 HEAD
指標正指向它),iss53
分支也隨之向前推進:
vim index.html
git commit -a -m 'added a new footer [issue 53]'
)
現在你接到電話,那個網站有一個問題需要立即修正; 有了 Git ,你就不用把你的緊急修正連同 iss53
尚未完成的內容一起部署(deploy)到正式環境;你也不用為了正確地套用修正而先花一大堆功夫回復之前 iss53
的修改; 唯一需要做的只是切換回發佈產品用的 master
分支。
然而,在切換分支之前,留意一下你的工作目錄或預存區(staging area)裡是否有還沒提交的內容,它可能會和你即要檢出的分支產生衝突(conflict),Git 會因此而不讓你切換分支; 所以切換分支的時候最好先保持一個乾淨的工作區域。 稍後會在 Stashing and Cleaning 中介紹幾個繞過這種問題的辦法(分別叫做「使用收藏(stashing)」和「提交的修訂方法(commit amending)」)。 目前先讓我們假設你已經提交了所有的變更,因此你可以切回 master
分支了:
git checkout master
Switched to branch 'master'
此時工作目錄中的內容和你在解決問題 #53 之前的內容一模一樣,你可以集中精力進行緊急修正了; 很重要的一點需要牢記:當你切換分支時,Git 會重置(reset)工作目錄內容,就像回到你在這個分支最後一次提交後的內容, 它會自動地增加、刪除和修改檔案以確保工作目錄的內容和當時的內容完全一樣。
接下來開始緊急修正; 讓我們建立一個緊急修正用的分支來進行工作,直到完成它:
git checkout -b hotfix
Switched to a new branch 'hotfix'
vim index.html
git commit -a -m 'fixed the broken email address'
[hotfix 1fb7853] fixed the broken email address
1 file changed, 2 insertions(+)
)
你可以跑一些測試以確保該修正是你想要的,然後切回 master 分支並把它合併進來,再部署到產品上; 用 git merge 命令來進行合併:
git checkout master
git merge hotfix
Updating f42c576..3a0874c
Fast-forward
index.html | 2 ++
1 file changed, 2 insertions(+)
注意合併時有一個「Fast-forward」字眼; 由於你要合併的分支 hotfix
所指向的提交 C4
直接超前了提交 C2
,Git 於是簡單地把分支指標向前推進; 換句話說,如果想要合併的提交可以直接往回追溯歷史到目前所在的提交,Git 會因為沒有需要合併的工作而簡單地把指標向前推進——這就是所謂的「快進(fast-forward)」。
現在你的修改已經含在 master
分支所指向的提交的快照中,你可以部署該修正了。
)
在那個超級重要的修正被部署以後,你準備要切回到之前被中斷而正在做的工作; 然而在那之前,你可以先刪除 hotfix
,因為你不再需要它了——master
也指向相同的提交; 使用 git branch
的 -d
選項執行刪除操作:
git branch -d hotfix
Deleted branch hotfix (3a0874c).
現在你可以切回到之前用來解決議題 #53 且仍在進展中的分支以繼續工作:
git checkout iss53
Switched to branch "iss53"
vim index.html
git commit -a -m 'finished the new footer [issue 53]'
[iss53 ad82d7a] finished the new footer [issue 53]
1 file changed, 1 insertion(+)
)
這裡值得注意的是之前 hotfix
分支的修改內容尚未包含到 iss53
分支的檔案中; 如果需要納入那個修正,你可以用 git merge master
把 master
分支合併到 iss53
分支;或者等 iss53
分支完成之後,再將它合併到 master
。
合併的基本用法
你已經完成了議題 #53 的工作,並準備好將它合併到 master
分支; 要完成這件事,你需要將 iss53
分支合併到 master
分支,實際操作和之前合併 hotfix
分支時差不多, 只需切回合併目的地的 master
分支,然後執行 git merge
命令:
git checkout master
Switched to branch 'master'
git merge iss53
Merge made by the 'recursive' strategy.
index.html | 1 +
1 file changed, 1 insertion(+)
這次的合併和之前合併 hotfix
的情況看起來有點不一樣; 在這種情況下,你的開發歷史是從一個較早的點便開始分離開來, 由於目前所在的提交(譯註:C4)並不是被合併的分支(譯註:iss53
,它指向 C5)的直接祖先,Git 必需進行一些處理; 就此例而言,Git 會用兩個分支末端的快照(譯註:C4、C5)以及它們的共同祖先(譯註:C2)進行一次簡單的三方合併(three-way merge)。
)
不同於將分支指標向前推進,Git 會對三方合併後的結果產生一個新的快照,並自動建立一個指向這個快照的提交(譯註:C6)。 這個提交被稱為「合併提交(merge commit)」,特別的是它的親代(parent)超過一個(譯註:C4 和 C5)。
)
值得一提的是 Git 會決定哪個共同祖先才是最佳合併基準;這一點和一些較舊的版控工具有所不同,像是 CVS 或 Subversion(1.5 以前的版本),它們需要開發者自己手動找出最佳合併基準; 這讓 Git 的合併操作比起其他系統都要簡單許多。
既然你的工作成果已經合併了,也就不再需要 iss53
分支了, 你可以在議題追蹤系統中關閉該議題,然後刪除這個分支:
git branch -d iss53
合併衝突的基本解法
有時候合併過程並不會如此順利, 如果在不同的分支中都修改了同一個檔案的同一部分,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 沒有自動產生新的合併提交, 它會暫停下來等你解決(resolve)衝突; 在合併衝突發生後的任何時候,如果你要看看哪些檔案還沒有合併,可以使用 git status
:
git status
On branch master
You have unmerged paths.
(fix conflicts and run "git commit")
Unmerged paths:
(use "git add <file>..." to mark resolution)
both modified: index.html
no changes added to commit (use "git add" and/or "git commit -a")
它會列出所有有合併衝突且仍未解決的檔案(譯註:列在 Unmerged paths:
下面); Git 會在有衝突的檔案裡加入標準的「衝突解決(conflict-resolution)」標記,因此你可以手動開啟它們以解決這些衝突; 你的檔案會包含類似下面這樣子的區段:
<<<<<<< HEAD:index.html
<div id="footer">contact : email.support@github.com</div>
=======
<div id="footer">
please contact us at support@github.com
</div>
>>>>>>> iss53:index.html
可以看到 =======
隔開的上半部分是 HEAD
(即 master
分支,在執行合併命令前所切換過去的分支)中的內容,下半部分則是在 iss53
分支中的內容; 解決衝突的辦法無非是二選一,或者由你自己合併內容; 比如你可以把這整段內容替換成以下內容而解決這個衝突:
<div id="footer">
please contact us at email.support@github.com
</div>
這個解決方案分別採納了兩個分支中的各一部分內容,並且完整地移除了 <<<<<<<
、=======
和 >>>>>>>
這些標記行。 在解決了每個衝突檔案裡的每個衝突後,對每個檔案執行 git add
會將它們標記為已解決狀態, 因為預存(stage)動作代表了衝突已經解決。
如果你想用圖形介面的工具來解決這些衝突,你可以執行 git mergetool
,它會呼叫一個適當的視覺化合併工具並引導你解決衝突:
$ git mergetool
This message is displayed because 'merge.tool' is not configured.
See 'git mergetool --tool-help' or 'git help config' for more details.
'git mergetool' will now attempt to use one of the following tools:
opendiff kdiff3 tkdiff xxdiff meld tortoisemerge gvimdiff diffuse diffmerge ecmerge p4merge araxis bc3 codecompare vimdiff emerge
Merging:
index.html
Normal merge conflict for 'index.html':
{local}: modified file
{remote}: modified file
Hit return to start merge resolution tool (opendiff):
如果不想用預設的合併工具(因為在 Mac 上執行了該命令,Git 預設選擇了 opendiff
),你可以在「one of the following tools」列表中找到可使用的合併工具, 然後只要輸入你想使用的工具名稱即可。
如果你需要更多進階的工具用來解決刁鑽的合併衝突,我們將在 Advanced Merging 介紹更多合併操作方法。
退出合併工具以後,Git 會詢問你合併是否成功, 如果回答「是」,它會幫你把相關檔案預存起來,將狀態標記為已解決; 你可以再次執行 git status
來確認所有衝突都已經解決:
$ git status
On branch master
All conflicts fixed but you are still merging.
(use "git commit" to conclude merge)
Changes to be committed:
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.
# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
# On branch master
# All conflicts fixed but you are still merging.
#
# Changes to be committed:
# modified: index.html
#
如果解決衝突的理由不是那麼明顯,或是想要幫助將來的人理解為何你要這樣解決衝突,你可以在訊息中提供更多的細節來說明。
分支管理
到目前為止,你已經建立、合併和刪除過分支(branch);讓我們再來看一些分支管理工具,這將會在你開始全程使用分支時派上用場。
git branch
命令不僅能建立和刪除分支, 如果不加任何參數,你將會得到所有分支的簡易清單:
git branch
iss53
* master
testing
注意 master
分支前面的 *
字元,它表示目前所檢出(checkout)的分支(換句話說,HEAD
指向這個分支); 這意味著如果你現在提交,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
-merged
和-no-merged
這兩個有用的選項,可以從該清單中篩選出已經合併或尚未合併到目前分支的分支。 使用git branch --merged
來查看哪些分支已被合併到目前分支:
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 fully merged.
If you are sure you want to delete it, run 'git branch -D testing'.
如果你確實想要刪除該分支並丟掉那個工作成果,可以用 -D
選項來強制執行,就像上面訊息中所提示的。
分支開發工作流
現在你已經學會新建和合並分支,那麽你可以或者應該用它來做些什麽呢? 在本節,我們會介紹一些常見的利用分支進行開發的工作流程。而正是由於分支管理的便捷, 才衍生出這些典型的工作模式,你可以根據項目實際情況選擇一種用用看。
長期分支 因為 Git 使用簡單的三方合並,所以就算在一段較長的時間內,反覆把一個分支合並入另一個分支,也不是什麽難事。 也就是說,在整個項目開發周期的不同階段,你可以同時擁有多個開放的分支;你可以定期地把某些主題分支合並入其他分支中。
許多使用 Git 的開發者都喜歡使用這種方式來工作,比如只在 master 分支上保留完全穩定的代碼——有可能僅僅是已經發布或即將發布的代碼。 他們還有一些名為 develop 或者 next 的平行分支,被用來做後續開發或者測試穩定性——這些分支不必保持絕對穩定, 但是一旦達到穩定狀態,它們就可以被合並入 master 分支了。 這樣,在確保這些已完成的主題分支(短期分支,比如之前的 iss53 分支)能夠通過所有測試,並且不會引入更多 bug 之後,就可以合並入主幹分支中,等待下一次的發布。
事實上我們剛才討論的,是隨著你的提交而不斷右移的指針。 穩定分支的指針總是在提交歷史中落後一大截,而前沿分支的指針往往比較靠前。
趨於穩定分支的線性圖。
通常把他們想象成流水線(work silos)可能更好理解一點,那些經過測試考驗的提交會被遴選到更加穩定的流水線上去。
)
趨於穩定分支的工作流(“silo”)視圖。
)
你可以用這種方法維護不同層次的穩定性。 一些大型項目還有一個 proposed(建議) 或 pu: proposed updates(建議更新)分支,它可能因包含一些不成熟的內容而不能進入 next 或者 master 分支。 這麽做的目的是使你的分支具有不同級別的穩定性;當它們具有一定程度的穩定性後,再把它們合並入具有更高級別穩定性的分支中。 再次強調一下,使用多個長期分支的方法並非必要,但是這麽做通常很有幫助,尤其是當你在一個非常龐大或者覆雜的項目中工作時。
主題分支
主題分支對任何規模的項目都適用。 主題分支是一種短期分支,它被用來實現單一特性或其相關工作。 也許你從來沒有在其他的版本控制系統(VCS)上這麽做過,因為在那些版本控制系統中創建和合並分支通常很費勁。 然而,在 Git 中一天之內多次創建、使用、合並、刪除分支都很常見。
你已經在上一節中你創建的 iss53 和 hotfix 主題分支中看到過這種用法。 你在上一節用到的主題分支(iss53 和 hotfix 分支)中提交了一些更新,並且在它們合並入主幹分支之後,你又刪除了它們。 這項技術能使你快速並且完整地進行上下文切換(context-switch)——因為你的工作被分散到不同的流水線中,在不同的流水線中每個分支都僅與其目標特性相關,因此,在做代碼審查之類的工作的時候就能更加容易地看出你做了哪 些改動。 你可以把做出的改動在主題分支中保留幾分鐘、幾天甚至幾個月,等它們成熟之後再合並,而不用在乎它們建立的順序或工作進度。
考慮這樣一個例子,你在 master 分支上工作到 C1,這時為了解決一個問題而新建 iss91 分支,在 iss91 分支上工作到 C4,然而對於那個問題你又有了新的想法,於是你再新建一個 iss91v2 分支試圖用另一種方法解決那個問題,接著你回到 master 分支工作了一會兒,你又冒出了一個不太確定的想法,你便在 C10 的時候新建一個 dumbidea 分支,並在上面做些實驗。 你的提交歷史看起來像下面這個樣子:
)
擁有多個主題分支的提交歷史。
)
現在,我們假設兩件事情:你決定使用第二個方案來解決那個問題,即使用在 iss91v2 分支中方案。 另外,你將 dumbidea 分支拿給你的同事看過之後,結果發現這是個驚人之舉。 這時你可以拋棄 iss91 分支(即丟棄 C5 和 C6 提交),然後把另外兩個分支合並入主幹分支。 最終你的提交歷史看起來像下面這個樣子,合並了 dumbidea
和 iss91v2
分支之後的提交歷史。
我們將會在 分布式 Git 中向你揭示更多有關分支工作流的細節, 因此,請確保你閱讀完那個章節之後,再來決定你的下個項目要使用什麽樣的分支策略(branching scheme)。
請牢記,當你做這麽多操作的時候,這些分支全部都存於本地。 當你新建和合並分支的時候,所有這一切都只發生在你本地的 Git 版本庫中 —— 沒有與服務器發生交互