Git詳解之六:Git工具

發表於2012-08-29

原文:《Pro Git》

Git 工具

現在,你已經學習了管理或者維護 Git 倉庫,實現程式碼控制所需的大多數日常命令和工作流程。你已經完成了跟蹤和提交檔案的基本任務,並且發揮了暫存區和輕量級的特性分支及合併的威力。(伯樂線上注:如果你對Git還不瞭解,建議從本Git系列第一篇文章開始閱讀)

接下來你將領略到一些 Git 可以實現的非常強大的功能,這些功能你可能並不會在日常操作中使用,但在某些時候你也許會需要。

6.1  修訂版本(Revision)選擇

Git 允許你通過幾種方法來指明特定的或者一定範圍內的提交。瞭解它們並不是必需的,但是瞭解一下總沒壞處。

單個修訂版本

顯然你可以使用給出的 SHA-1 值來指明一次提交,不過也有更加人性化的方法來做同樣的事。本節概述了指明單個提交的諸多方法。

簡短的SHA

Git 很聰明,它能夠通過你提供的前幾個字元來識別你想要的那次提交,只要你提供的那部分 SHA-1 不短於四個字元,並且沒有歧義——也就是說,當前倉庫中只有一個物件以這段 SHA-1 開頭。

例如,想要檢視一次指定的提交,假設你執行 git log 命令並找到你增加了功能的那次提交:

假設是 1c002dd.... 。如果你想 git show 這次提交,下面的命令是等價的(假設簡短的版本沒有歧義):

Git 可以為你的 SHA-1 值生成出簡短且唯一的縮寫。如果你傳遞 --abbrev-commit 給 git log 命令,輸出結果裡就會使用簡短且唯一的值;它預設使用七個字元來表示,不過必要時為了避免 SHA-1 的歧義,會增加字元數:

通常在一個專案中,使用八到十個字元來避免 SHA-1 歧義已經足夠了。最大的 Git 專案之一,Linux 核心,目前也只需要最長 40 個字元中的 12 個字元來保持唯一性。

關於 SHA-1 的簡短說明

許多人可能會擔心一個問題:在隨機的偶然情況下,在他們的倉庫裡會出現兩個具有相同 SHA-1 值的物件。那會怎麼樣呢?

如果你真的向倉庫裡提交了一個跟之前的某個物件具有相同 SHA-1 值的物件,Git 將會發現之前的那個物件已經存在在 Git 資料庫中,並認為它已經被寫入了。如果什麼時候你想再次檢出那個物件時,你會總是得到先前的那個物件的資料。

不過,你應該瞭解到,這種情況發生的概率是多麼微小。SHA-1 摘要長度是 20 位元組,也就是 160 位。為了保證有 50% 的概率出現一次衝突,需要 2^80 個隨機雜湊的物件(計算衝突機率的公式是p = (n(n-1)/2) * (1/2^160))。2^80 是 1.2 x 10^24,也就是一億億億,那是地球上沙粒總數的 1200 倍。

現在舉例說一下怎樣才能產生一次 SHA-1 衝突。如果地球上 65 億的人類都在程式設計,每人每秒都在產生等價於整個 Linux 核心歷史(一百萬個 Git 物件)的程式碼,並將之提交到一個巨大的 Git 倉庫裡面,那將花費 5 年的時間才會產生足夠的物件,使其擁有 50% 的概率產生一次 SHA-1 物件衝突。這要比你程式設計團隊的成員同一個晚上在互不相干的意外中被狼襲擊並殺死的機率還要小。

分支引用

指明一次提交的最直接的方法要求有一個指向它的分支引用。這樣,你就可以在任何需要一個提交物件或者 SHA-1 值的 Git 命令中使用該分支名稱了。如果你想要顯示一個分支的最後一次提交的物件,例如假設topic1 分支指向 ca82a6d,那麼下面的命令是等價的:

如果你想知道某個分支指向哪個特定的 SHA,或者想看任何一個例子中被簡寫的 SHA-1,你可以使用一個叫做 rev-parse的 Git 探測工具。在第 9 章你可以看到關於探測工具的更多資訊;簡單來說,rev-parse 是為了底層操作而不是日常操作設計的。不過,有時你想看 Git 現在到底處於什麼狀態時,它可能會很有用。這裡你可以對你的分支運執行rev-parse

引用日誌裡的簡稱

在你工作的同時,Git 在後臺的工作之一就是儲存一份引用日誌——一份記錄最近幾個月你的 HEAD 和分支引用的日誌。

你可以使用 git reflog 來檢視引用日誌:

每次你的分支頂端因為某些原因被修改時,Git 就會為你將資訊儲存在這個臨時歷史記錄裡面。你也可以使用這份資料來指明更早的分支。如果你想檢視倉庫中 HEAD 在五次前的值,你可以使用引用日誌的輸出中的@{n} 引用:

你也可以使用這個語法來檢視一定時間前分支指向哪裡。例如,想看你的 master 分支昨天在哪,你可以輸入

它就會顯示昨天分支的頂端在哪。這項技術只對還在你引用日誌裡的資料有用,所以不能用來檢視比幾個月前還早的提交。

想要看類似於 git log 輸出格式的引用日誌資訊,你可以執行 git log -g

需要注意的是,日誌引用資訊只存在於本地——這是一個你在倉庫裡做過什麼的日誌。其他人的倉庫拷貝里的引用和你的相同;而你新克隆一個倉庫的時候,引用日誌是空的,因為你在倉庫裡還沒有操作。只有你克隆了一個專案至少兩個月,git show HEAD@{2.months.ago} 才會有用——如果你是五分鐘前克隆的倉庫,將不會有結果返回。

祖先引用

另一種指明某次提交的常用方法是通過它的祖先。如果你在引用最後加上一個 ^,Git 將其理解為此次提交的父提交。 假設你的工程歷史是這樣的:

那麼,想看上一次提交,你可以使用 HEAD^,意思是“HEAD 的父提交”:

你也可以在 ^ 後新增一個數字——例如,d921970^2 意思是“d921970 的第二父提交”。這種語法只在合併提交時有用,因為合併提交可能有多個父提交。第一父提交是你合併時所在分支,而第二父提交是你所合併的分支:

另外一個指明祖先提交的方法是 ~。這也是指向第一父提交,所以 HEAD~ 和 HEAD^ 是等價的。當你指定數字的時候就明顯不一樣了。HEAD~2 是指“第一父提交的第一父提交”,也就是“祖父提交”——它會根據你指定的次數檢索第一父提交。例如,在上面列出的歷史記錄裡面,HEAD~3 會是

也可以寫成 HEAD^^^,同樣是第一父提交的第一父提交的第一父提交:

你也可以混合使用這些語法——你可以通過 HEAD~3^2 指明先前引用的第二父提交(假設它是一個合併提交)。 github

提交範圍

現在你已經可以指明單次的提交,讓我們來看看怎樣指明一定範圍的提交。這在你管理分支的時候尤顯重要——如果你有很多分支,你可以指明範圍來圈定一些問題的答案,比如:“這個分支上我有哪些工作還沒合併到主分支的?”

雙點

最常用的指明範圍的方法是雙點的語法。這種語法主要是讓 Git 區分出可從一個分支中獲得而不能從另一個分支中獲得的提交。例如,假設你有類似於圖 6-1 的提交歷史。

圖 6-1. 範圍選擇的提交歷史例項

你想要檢視你的試驗分支上哪些沒有被提交到主分支,那麼你就可以使用 master..experiment 來讓 Git 顯示這些提交的日誌——這句話的意思是“所有可從experiment分支中獲得而不能從master分支中獲得的提交”。為了使例子簡單明瞭,我使用了圖示中提交物件的字母來代替真實日誌的輸出,所以會顯示:

另一方面,如果你想看相反的——所有在 master 而不在 experiment 中的分支——你可以交換分支的名字。experiment..master 顯示所有可在master 獲得而在 experiment 中不能的提交:

這在你想保持 experiment 分支最新和預覽你將合併的提交的時候特別有用。這個語法的另一種常見用途是檢視你將把什麼推送到遠端:

這條命令顯示任何在你當前分支上而不在遠端origin 上的提交。如果你執行 git push 並且的你的當前分支正在跟蹤origin/master,被git log origin/master..HEAD 列出的提交就是將被傳輸到伺服器上的提交。 你也可以留空語法中的一邊來讓 Git 來假定它是 HEAD。例如,輸入git log origin/master.. 將得到和上面的例子一樣的結果—— Git 使用 HEAD 來代替不存在的一邊。

多點

雙點語法就像速記一樣有用;但是你也許會想針對兩個以上的分支來指明修訂版本,比如檢視哪些提交被包含在某些分支中的一個,但是不在你當前的分支上。Git允許你在引用前使用^字元或者--not指明你不希望提交被包含其中的分支。因此下面三個命令是等同的:

這樣很好,因為它允許你在查詢中指定多於兩個的引用,而這是雙點語法所做不到的。例如,如果你想查詢所有從refArefB包含的但是不被refC包含的提交,你可以輸入下面中的一個

這建立了一個非常強大的修訂版本查詢系統,應該可以幫助你解決分支裡包含了什麼這個問題。

三點

最後一種主要的範圍選擇語法是三點語法,這個可以指定被兩個引用中的一個包含但又不被兩者同時包含的分支。回過頭來看一下圖6-1裡所列的提交歷史的例子。 如果你想檢視master或者experiment中包含的但不是兩者共有的引用,你可以執行

這個再次給出你普通的log輸出但是隻顯示那四次提交的資訊,按照傳統的提交日期排列。

這種情形下,log命令的一個常用引數是--left-right,它會顯示每個提交到底處於哪一側的分支。這使得資料更加有用。

有了以上工具,讓Git知道你要察看哪些提交就容易得多了。

6.2  互動式暫存

Git提供了很多指令碼來輔助某些命令列任務。這裡,你將看到一些互動式命令,它們幫助你方便地構建只包含特定組合和部分檔案的提交。在你修改了一大批檔案然後決定將這些變更分佈在幾個各有側重的提交而不是單個又大又亂的提交時,這些工具非常有用。用這種方法,你可以確保你的提交在邏輯上劃分為相應的變更集,以便於供和你一起工作的開發者審閱。如果你執行git add時加上-i或者--interactive選項,Git就進入了一個互動式的shell模式,顯示一些類似於下面的資訊:

你會看到這個命令以一個完全不同的檢視顯示了你的暫存區——主要是你通過git status得到的那些資訊但是稍微簡潔但資訊更加豐富一些。它在左側列出了你暫存的變更,在右側列出了未被暫存的變更。

在這之後是一個命令區。這裡你可以做很多事情,包括暫存檔案,撤回檔案,暫存部分檔案,加入未被追蹤的檔案,檢視暫存檔案的差別。

暫存和撤回檔案

如果你在What now>的提示後輸入2或者u,這個指令碼會提示你那些檔案你想要暫存:

如果想暫存TODO和index.html,你可以輸入相應的編號:

每個檔案旁邊的*表示選中的檔案將被暫存。如果你在update>>提示後直接敲入回車,Git會替你把所有選中的內容暫存:

現在你可以看到TODO和index.html檔案被暫存了同時simplegit.rb檔案仍然未被暫存。如果這時你想要撤回TODO檔案,就使用3或者r(代表revert,恢復)選項:

再次檢視Git的狀態,你會看到你已經撤回了TODO檔案

要檢視你暫存內容的差異,你可以使用6或者d(表示diff)命令。它會顯示你暫存檔案的列表,你可以選擇其中的幾個,顯示其被暫存的差異。這跟你在命令列下指定git diff --cached非常相似:

+

通過這些基本命令,你可以使用互動式增加模式更加方便地處理暫存區。

暫存補丁

只讓Git暫存檔案的某些部分而忽略其他也是有可能的。例如,你對simplegit.rb檔案作了兩處修改但是隻想暫存其中一個而忽略另一個,在Git中實現這一點非常容易。在互動式的提示符下,輸入5或者p(表示patch,補丁)。Git會詢問哪些檔案你希望部分暫存;然後對於被選中檔案的每一節,他會逐個顯示檔案的差異區塊並詢問你是否希望暫存他們:

此處你有很多選擇。輸入?可以顯示列表:

如果你想暫存各個區塊,通常你會輸入y或者n,但是暫存特定檔案裡的全部區塊或者暫時跳過對一個區塊的處理同樣也很有用。如果你暫存了檔案的一個部分而保留另外一個部分不被暫存,你的狀態輸出看起來會是這樣:

simplegit.rb的狀態非常有意思。它顯示有幾行被暫存了,有幾行沒有。你部分地暫存了這個檔案。在這時,你可以退出互動式指令碼然後執行git commit來提交部分暫存的檔案。

最後你也可以不通過互動式增加的模式來實現部分檔案暫存——你可以在命令列下通過git add -p或者git add --patch來啟動同樣的指令碼。

6.3  儲藏(Stashing)

經常有這樣的事情發生,當你正在進行專案中某一部分的工作,裡面的東西處於一個比較雜亂的狀態,而你想轉到其他分支上進行一些工作。問題是,你不想提交進行了一半的工作,否則以後你無法回到這個工作點。解決這個問題的辦法就是git stash命令。

“‘儲藏”“可以獲取你工作目錄的中間狀態——也就是你修改過的被追蹤的檔案和暫存的變更——並將它儲存到一個未完結變更的堆疊中,隨時可以重新應用。

儲藏你的工作

為了演示這一功能,你可以進入你的專案,在一些檔案上進行工作,有可能還暫存其中一個變更。如果你執行 git status,你可以看到你的中間狀態:

現在你想切換分支,但是你還不想提交你正在進行中的工作;所以你儲藏這些變更。為了往堆疊推送一個新的儲藏,只要執行 git stash

你的工作目錄就乾淨了:

這時,你可以方便地切換到其他分支工作;你的變更都儲存在棧上。要檢視現有的儲藏,你可以使用 git stash list

在這個案例中,之前已經進行了兩次儲藏,所以你可以訪問到三個不同的儲藏。你可以重新應用你剛剛實施的儲藏,所採用的命令就是之前在原始的 stash 命令的幫助輸出裡提示的:git stash apply。如果你想應用更早的儲藏,你可以通過名字指定它,像這樣:git stash apply stash@{2}。如果你不指明,Git 預設使用最近的儲藏並嘗試應用它:

你可以看到 Git 重新修改了你所儲藏的那些當時尚未提交的檔案。在這個案例裡,你嘗試應用儲藏的工作目錄是乾淨的,並且屬於同一分支;但是一個乾淨的工作目錄和應用到相同的分支上並不是應用儲藏的必要條件。你可以在其中一個分支上保留一份儲藏,隨後切換到另外一個分支,再重新應用這些變更。在工作目錄裡包含已修改和未提交的檔案時,你也可以應用儲藏——Git 會給出歸併衝突如果有任何變更無法乾淨地被應用。

對檔案的變更被重新應用,但是被暫存的檔案沒有重新被暫存。想那樣的話,你必須在執行 git stash apply 命令時帶上一個 --index 的選項來告訴命令重新應用被暫存的變更。如果你是這麼做的,你應該已經回到你原來的位置:

apply 選項只嘗試應用儲藏的工作——儲藏的內容仍然在棧上。要移除它,你可以執行 git stash drop,加上你希望移除的儲藏的名字:

你也可以執行 git stash pop 來重新應用儲藏,同時立刻將其從堆疊中移走。

Un-applying a Stash

In some use case scenarios you might want to apply stashed changes, do some work, but then un-apply those changes that originally came form the stash. Git does not provide such astash unapply command, but it is possible to achieve the effect by simply retrieving the patch associated with a stash and applying it in reverse:

Again, if you don’t specify a stash, Git assumes the most recent stash:

You may want to create an alias and effectively add a stash-unapply command to your git. For example:

從儲藏中建立分支

如果你儲藏了一些工作,暫時不去理會,然後繼續在你儲藏工作的分支上工作,你在重新應用工作時可能會碰到一些問題。如果嘗試應用的變更是針對一個你那之後修改過的檔案,你會碰到一個歸併衝突並且必須去化解它。如果你想用更方便的方法來重新檢驗你儲藏的變更,你可以執行git stash branch,這會建立一個新的分支,檢出你儲藏工作時的所處的提交,重新應用你的工作,如果成功,將會丟棄儲藏。

這是一個很棒的捷徑來恢復儲藏的工作然後在新的分支上繼續當時的工作。

6.4  重寫歷史

很多時候,在 Git 上工作的時候,你也許會由於某種原因想要修訂你的提交歷史。Git 的一個卓越之處就是它允許你在最後可能的時刻再作決定。你可以在你即將提交暫存區時決定什麼檔案歸入哪一次提交,你可以使用 stash 命令來決定你暫時擱置的工作,你可以重寫已經發生的提交以使它們看起來是另外一種樣子。這個包括改變提交的次序、改變說明或者修改提交中包含的檔案,將提交歸併、拆分或者完全刪除——這一切在你尚未開始將你的工作和別人共享前都是可以的。

在這一節中,你會學到如何完成這些很有用的任務以使你的提交歷史在你將其共享給別人之前變成你想要的樣子。

改變最近一次提交

改變最近一次提交也許是最常見的重寫歷史的行為。對於你的最近一次提交,你經常想做兩件基本事情:改變提交說明,或者改變你剛剛通過增加,改變,刪除而記錄的快照。

如果你只想修改最近一次提交說明,這非常簡單:

這會把你帶入文字編輯器,裡面包含了你最近一次提交說明,供你修改。當你儲存並退出編輯器,這個編輯器會寫入一個新的提交,裡面包含了那個說明,並且讓它成為你的新的最近一次提交。

如果你完成提交後又想修改被提交的快照,增加或者修改其中的檔案,可能因為你最初提交時,忘了新增一個新建的檔案,這個過程基本上一樣。你通過修改檔案然後對其執行git add或對一個已被記錄的檔案執行git rm,隨後的git commit --amend會獲取你當前的暫存區並將它作為新提交對應的快照。

使用這項技術的時候你必須小心,因為修正會改變提交的SHA-1值。這個很像是一次非常小的rebase——不要在你最近一次提交被推送後還去修正它。

修改多個提交說明

要修改歷史中更早的提交,你必須採用更復雜的工具。Git沒有一個修改歷史的工具,但是你可以使用rebase工具來衍合一系列的提交到它們原來所在的HEAD上而不是移到新的上。依靠這個互動式的rebase工具,你就可以停留在每一次提交後,如果你想修改或改變說明、增加檔案或任何其他事情。你可以通過給git rebase增加-i選項來以互動方式地執行rebase。你必須通過告訴命令衍合到哪次提交,來指明你需要重寫的提交的回溯深度。

例如,你想修改最近三次的提交說明,或者其中任意一次,你必須給git rebase -i提供一個引數,指明你想要修改的提交的父提交,例如HEAD~2或者HEAD~3。可能記住~3更加容易,因為你想修改最近三次提交;但是請記住你事實上所指的是四次提交之前,即你想修改的提交的父提交。

再次提醒這是一個衍合命令——HEAD~3..HEAD範圍內的每一次提交都會被重寫,無論你是否修改說明。不要涵蓋你已經推送到中心伺服器的提交——這麼做會使其他開發者產生混亂,因為你提供了同樣變更的不同版本。

執行這個命令會為你的文字編輯器提供一個提交列表,看起來像下面這樣

很重要的一點是你得注意這些提交的順序與你通常通過log命令看到的是相反的。如果你執行log,你會看到下面這樣的結果:

請注意這裡的倒序。互動式的rebase給了你一個即將執行的指令碼。它會從你在命令列上指明的提交開始(HEAD~3)然後自上至下重播每次提交裡引入的變更。它將最早的列在頂上而不是最近的,因為這是第一個需要重播的。

你需要修改這個指令碼來讓它停留在你想修改的變更上。要做到這一點,你只要將你想修改的每一次提交前面的pick改為edit。例如,只想修改第三次提交說明的話,你就像下面這樣修改檔案:

當你儲存並退出編輯器,Git會倒回至列表中的最後一次提交,然後把你送到命令列中,同時顯示以下資訊:

這些指示很明確地告訴了你該幹什麼。輸入

修改提交說明,退出編輯器。然後,執行

這個命令會自動應用其他兩次提交,你就完成任務了。如果你將更多行的 pick 改為 edit ,你就能對你想修改的提交重複這些步驟。Git每次都會停下,讓你修正提交,完成後繼續執行。

重排提交

你也可以使用互動式的衍合來徹底重排或刪除提交。如果你想刪除”added cat-file”這個提交併且修改其他兩次提交引入的順序,你將rebase指令碼從這個

改為這個:

當你儲存並退出編輯器,Git 將分支倒回至這些提交的父提交,應用310154e,然後f7f3f6d,接著停止。你有效地修改了這些提交的順序並且徹底刪除了”added cat-file”這次提交。

壓制(Squashing)提交

互動式的衍合工具還可以將一系列提交壓制為單一提交。指令碼在 rebase 的資訊裡放了一些有用的指示:

如果不用”pick”或者”edit”,而是指定”squash”,Git 會同時應用那個變更和它之前的變更並將提交說明歸併。因此,如果你想將這三個提交合併為單一提交,你可以將指令碼修改成這樣:

當你儲存並退出編輯器,Git 會應用全部三次變更然後將你送回編輯器來歸併三次提交說明。

當你儲存之後,你就擁有了一個包含前三次提交的全部變更的單一提交。

拆分提交

拆分提交就是撤銷一次提交,然後多次部分地暫存或提交直到結束。例如,假設你想將三次提交中的中間一次拆分。將”updated README formatting and added blame”拆分成兩次提交:第一次為”updated README formatting”,第二次為”added blame”。你可以在rebase -i指令碼中修改你想拆分的提交前的指令為”edit”:

然後,這個指令碼就將你帶入命令列,你重置那次提交,提取被重置的變更,從中建立多次提交。當你儲存並退出編輯器,Git 倒回到列表中第一次提交的父提交,應用第一次提交(f7f3f6d),應用第二次提交(310154e),然後將你帶到控制檯。那裡你可以用git reset HEAD^對那次提交進行一次混合的重置,這將撤銷那次提交併且將修改的檔案撤回。此時你可以暫存並提交檔案,直到你擁有多次提交,結束後,執行git rebase --continue

Git在指令碼中應用了最後一次提交(a5f4a0d),你的歷史看起來就像這樣了:

再次提醒,這會修改你列表中的提交的 SHA 值,所以請確保這個列表裡不包含你已經推送到共享倉庫的提交。

核彈級選項: filter-branch

如果你想用指令碼的方式修改大量的提交,還有一個重寫歷史的選項可以用——例如,全域性性地修改電子郵件地址或者將一個檔案從所有提交中刪除。這個命令是filter-branch,這個會大面積地修改你的歷史,所以你很有可能不該去用它,除非你的專案尚未公開,沒有其他人在你準備修改的提交的基礎上工作。儘管如此,這個可以非常有用。你會學習一些常見用法,藉此對它的能力有所認識。

從所有提交中刪除一個檔案

這個經常發生。有些人不經思考使用git add .,意外地提交了一個巨大的二進位制檔案,你想將它從所有地方刪除。也許你不小心提交了一個包含密碼的檔案,而你想讓你的專案開源。filter-branch大概會是你用來清理整個歷史的工具。要從整個歷史中刪除一個名叫password.txt的檔案,你可以在filter-branch上使用--tree-filter選項:

--tree-filter選項會在每次檢出專案時先執行指定的命令然後重新提交結果。在這個例子中,你會在所有快照中刪除一個名叫 password.txt 的檔案,無論它是否存在。如果你想刪除所有不小心提交上去的編輯器備份檔案,你可以執行類似git filter-branch --tree-filter 'rm -f *~' HEAD的命令。

你可以觀察到 Git 重寫目錄樹並且提交,然後將分支指標移到末尾。一個比較好的辦法是在一個測試分支上做這些然後在你確定產物真的是你所要的之後,再 hard-reset 你的主分支。要在你所有的分支上執行filter-branch的話,你可以傳遞一個--all給命令。

將一個子目錄設定為新的根目錄

假設你完成了從另外一個程式碼控制系統的匯入工作,得到了一些沒有意義的子目錄(trunk, tags等等)。如果你想讓trunk子目錄成為每一次提交的新的專案根目錄,filter-branch也可以幫你做到:

現在你的專案根目錄就是trunk子目錄了。Git 會自動地刪除不對這個子目錄產生影響的提交。

全域性性地更換電子郵件地址

另一個常見的案例是你在開始時忘了執行git config來設定你的姓名和電子郵件地址,也許你想開源一個專案,把你所有的工作電子郵件地址修改為個人地址。無論哪種情況你都可以用filter-branch來更換多次提交裡的電子郵件地址。你必須小心一些,只改變屬於你的電子郵件地址,所以你使用--commit-filter

這個會遍歷並重寫所有提交使之擁有你的新地址。因為提交裡包含了它們的父提交的SHA-1值,這個命令會修改你的歷史中的所有提交,而不僅僅是包含了匹配的電子郵件地址的那些。

6.5  使用 Git 除錯

Git 同樣提供了一些工具來幫助你除錯專案中遇到的問題。由於 Git 被設計為可應用於幾乎任何型別的專案,這些工具是通用型,但是在遇到問題時可以經常幫助你查詢缺陷所在。

檔案標註

如果你在追蹤程式碼中的缺陷想知道這是什麼時候為什麼被引進來的,檔案標註會是你的最佳工具。它會顯示檔案中對每一行進行修改的最近一次提交。因此,如果你發現自己程式碼中的一個方法存在缺陷,你可以用git blame來標註檔案,檢視那個方法的每一行分別是由誰在哪一天修改的。下面這個例子使用了-L選項來限制輸出範圍在第12至22行:

請注意第一個域裡是最後一次修改該行的那次提交的 SHA-1 值。接下去的兩個域是從那次提交中抽取的值——作者姓名和日期——所以你可以方便地獲知誰在什麼時候修改了這一行。在這後面是行號和檔案的內容。請注意^4832fe2提交的那些行,這些指的是檔案最初提交的那些行。那個提交是檔案第一次被加入這個專案時存在的,自那以後未被修改過。這會帶來小小的困惑,因為你已經至少看到了Git使用^來修飾一個提交的SHA值的三種不同的意義,但這裡確實就是這個意思。

另一件很酷的事情是在 Git 中你不需要顯式地記錄檔案的重新命名。它會記錄快照然後根據現實嘗試找出隱式的重新命名動作。這其中有一個很有意思的特性就是你可以讓它找出所有的程式碼移動。如果你在git blame後加上-C,Git會分析你在標註的檔案然後嘗試找出其中程式碼片段的原始出處,如果它是從其他地方拷貝過來的話。最近,我在將一個名叫GITServerHandler.m的檔案分解到多個檔案中,其中一個是GITPackUpload.m。通過對GITPackUpload.m執行帶-C引數的blame命令,我可以看到程式碼塊的原始出處:

這真的非常有用。通常,你會把你拷貝程式碼的那次提交作為原始提交,因為這是你在這個檔案中第一次接觸到那幾行。Git可以告訴你編寫那些行的原始提交,即便是在另一個檔案裡。

二分查詢

標註檔案在你知道問題是哪裡引入的時候會有幫助。如果你不知道,並且自上次程式碼可用的狀態已經經歷了上百次的提交,你可能就要求助於bisect命令了。bisect會在你的提交歷史中進行二分查詢來儘快地確定哪一次提交引入了錯誤。

例如你剛剛推送了一個程式碼釋出版本到產品環境中,對程式碼為什麼會表現成那樣百思不得其解。你回到你的程式碼中,還好你可以重現那個問題,但是找不到在哪裡。你可以對程式碼執行bisect來尋找。首先你執行git bisect start啟動,然後你用git bisect bad來告訴系統當前的提交已經有問題了。然後你必須告訴bisect已知的最後一次正常狀態是哪次提交,使用git bisect good [good_commit]

Git 發現在你標記為正常的提交(v1.0)和當前的錯誤版本之間有大約12次提交,於是它檢出中間的一個。在這裡,你可以執行測試來檢查問題是否存在於這次提交。如果是,那麼它是在這個中間提交之前的某一次引入的;如果否,那麼問題是在中間提交之後引入的。假設這裡是沒有錯誤的,那麼你就通過git bisect good來告訴 Git 然後繼續你的旅程:

現在你在另外一個提交上了,在你剛剛測試通過的和一個錯誤提交的中點處。你再次執行測試然後發現這次提交是錯誤的,因此你通過git bisect bad來告訴Git:

這次提交是好的,那麼 Git 就獲得了確定問題引入位置所需的所有資訊。它告訴你第一個錯誤提交的 SHA-1 值並且顯示一些提交說明以及哪些檔案在那次提交裡修改過,這樣你可以找出缺陷被引入的根源:

當你完成之後,你應該執行git bisect reset來重設你的HEAD到你開始前的地方,否則你會處於一個詭異的地方:

這是個強大的工具,可以幫助你檢查上百的提交,在幾分鐘內找出缺陷引入的位置。事實上,如果你有一個指令碼會在工程正常時返回0,錯誤時返回非0的話,你可以完全自動地執行git bisect。首先你需要提供已知的錯誤和正確提交來告訴它二分查詢的範圍。你可以通過bisect start命令來列出它們,先列出已知的錯誤提交再列出已知的正確提交:

這樣會自動地在每一個檢出的提交裡執行test-error.sh直到Git找出第一個破損的提交。你也可以執行像make或者make tests或者任何你所擁有的來為你執行自動化的測試。

6.6  子模組

經常有這樣的事情,當你在一個專案上工作時,你需要在其中使用另外一個專案。也許它是一個第三方開發的庫或者是你獨立開發和並在多個父專案中使用的。這個場景下一個常見的問題產生了:你想將兩個專案單獨處理但是又需要在其中一箇中使用另外一個。

這裡有一個例子。假設你在開發一個網站,為之建立Atom源。你不想編寫一個自己的Atom生成程式碼,而是決定使用一個庫。你可能不得不像CPAN install或者Ruby gem一樣包含來自共享庫的程式碼,或者將程式碼拷貝到你的專案樹中。如果採用包含庫的辦法,那麼不管用什麼辦法都很難去定製這個庫,部署它就更加困難了,因為你必須確保每個客戶都擁有那個庫。把程式碼包含到你自己的專案中帶來的問題是,當上遊被修改時,任何你進行的定製化的修改都很難歸併。

Git 通過子模組處理這個問題。子模組允許你將一個 Git 倉庫當作另外一個Git倉庫的子目錄。這允許你克隆另外一個倉庫到你的專案中並且保持你的提交相對獨立。

子模組初步

假設你想把 Rack 庫(一個 Ruby 的 web 伺服器閘道器介面)加入到你的專案中,可能既要保持你自己的變更,又要延續上游的變更。首先你要把外部的倉庫克隆到你的子目錄中。你通過git submodule add將外部專案加為子模組:

現在你就在專案裡的rack子目錄下有了一個 Rack 專案。你可以進入那個子目錄,進行變更,加入你自己的遠端可寫倉庫來推送你的變更,從原始倉庫拉取和歸併等等。如果你在加入子模組後立刻執行git status,你會看到下面兩項:

首先你注意到有一個.gitmodules檔案。這是一個配置檔案,儲存了專案 URL 和你拉取到的本地子目錄

如果你有多個子模組,這個檔案裡會有多個條目。很重要的一點是這個檔案跟其他檔案一樣也是處於版本控制之下的,就像你的.gitignore檔案一樣。它跟專案裡的其他檔案一樣可以被推送和拉取。這是其他克隆此專案的人獲知子模組專案來源的途徑。

git status的輸出裡所列的另一專案是 rack 。如果你執行在那上面執行git diff,會發現一些有趣的東西:

儘管rack是你工作目錄裡的子目錄,但 Git 把它視作一個子模組,當你不在那個目錄裡時並不記錄它的內容。取而代之的是,Git 將它記錄成來自那個倉庫的一個特殊的提交。當你在那個子目錄裡修改並提交時,子專案會通知那裡的 HEAD 已經發生變更並記錄你當前正在工作的那個提交;通過那樣的方法,當其他人克隆此專案,他們可以重新建立一致的環境。

這是關於子模組的重要一點:你記錄他們當前確切所處的提交。你不能記錄一個子模組的master或者其他的符號引用。

當你提交時,會看到類似下面的:

注意 rack 條目的 160000 模式。這在Git中是一個特殊模式,基本意思是你將一個提交記錄為一個目錄項而不是子目錄或者檔案。

你可以將rack目錄當作一個獨立的專案,保持一個指向子目錄的最新提交的指標然後反覆地更新上層專案。所有的Git命令都在兩個子目錄裡獨立工作:

克隆一個帶子模組的專案

這裡你將克隆一個帶子模組的專案。當你接收到這樣一個專案,你將得到了包含子專案的目錄,但裡面沒有檔案:

rack目錄存在了,但是是空的。你必須執行兩個命令:git submodule init來初始化你的本地配置檔案,git submodule update來從那個專案拉取所有資料並檢出你上層專案裡所列的合適的提交:

現在你的rack子目錄就處於你先前提交的確切狀態了。如果另外一個開發者變更了 rack 的程式碼並提交,你拉取那個引用然後歸併之,將得到稍有點怪異的東西:

你歸併來的僅僅上是一個指向你的子模組的指標;但是它並不更新你子模組目錄裡的程式碼,所以看起來你的工作目錄處於一個臨時狀態:

事情就是這樣,因為你所擁有的子模組的指標並對應於子模組目錄的真實狀態。為了修復這一點,你必須再次執行git submodule update

每次你從主專案中拉取一個子模組的變更都必須這樣做。看起來很怪但是管用。

一個常見問題是當開發者對子模組做了一個本地的變更但是並沒有推送到公共伺服器。然後他們提交了一個指向那個非公開狀態的指標然後推送上層專案。當其他開發者試圖執行git submodule update,那個子模組系統會找不到所引用的提交,因為它只存在於第一個開發者的系統中。如果發生那種情況,你會看到類似這樣的錯誤:

你不得不去檢視誰最後變更了子模組

然後,你給那個傢伙發電子郵件說他一通。

上層專案

有時候,開發者想按照他們的分組獲取一個大專案的子目錄的子集。如果你是從 CVS 或者 Subversion 遷移過來的話這個很常見,在那些系統中你已經定義了一個模組或者子目錄的集合,而你想延續這種型別的工作流程。

在 Git 中實現這個的一個好辦法是你將每一個子目錄都做成獨立的 Git 倉庫,然後建立一個上層專案的 Git 倉庫包含多個子模組。這個辦法的一個優勢是你可以在上層專案中通過標籤和分支更為明確地定義專案之間的關係。

子模組的問題

使用子模組並非沒有任何缺點。首先,你在子模組目錄中工作時必須相對小心。當你執行git submodule update,它會檢出專案的指定版本,但是不在分支內。這叫做獲得一個分離的頭——這意味著 HEAD 檔案直接指向一次提交,而不是一個符號引用。問題在於你通常並不想在一個分離的頭的環境下工作,因為太容易丟失變更了。如果你先執行了一次submodule update,然後在那個子模組目錄裡不建立分支就進行提交,然後再次從上層專案裡執行git submodule update同時不進行提交,Git會毫無提示地覆蓋你的變更。技術上講你不會丟失工作,但是你將失去指向它的分支,因此會很難取到。

為了避免這個問題,當你在子模組目錄裡工作時應使用git checkout -b work建立一個分支。當你再次在子模組裡更新的時候,它仍然會覆蓋你的工作,但是至少你擁有一個可以回溯的指標。

切換帶有子模組的分支同樣也很有技巧。如果你建立一個新的分支,增加了一個子模組,然後切換回不帶該子模組的分支,你仍然會擁有一個未被追蹤的子模組的目錄

你將不得不將它移走或者刪除,這樣的話當你切換回去的時候必須重新克隆它——你可能會丟失你未推送的本地的變更或分支。

最後一個需要引起注意的是關於從子目錄切換到子模組的。如果你已經跟蹤了你專案中的一些檔案但是想把它們移到子模組去,你必須非常小心,否則Git會生你的氣。假設你的專案中有一個子目錄裡放了 rack 的檔案,然後你想將它轉換為子模組。如果你刪除子目錄然後執行submodule add,Git會向你大吼:

你必須先將rack目錄撤回。然後你才能加入子模組:

現在假設你在一個分支裡那樣做了。如果你嘗試切換回一個仍然在目錄裡保留那些檔案而不是子模組的分支時——你會得到下面的錯誤:

你必須先移除rack子模組的目錄才能切換到不包含它的分支:

然後,當你切換回來,你會得到一個空的rack目錄。你可以執行git submodule update重新克隆,也可以將/tmp/rack目錄重新移回空目錄。

6.7  子樹合併

現在你已經看到了子模組系統的麻煩之處,讓我們來看一下解決相同問題的另一途徑。當 Git 歸併時,它會檢查需要歸併的內容然後選擇一個合適的歸併策略。如果你歸併的分支是兩個,Git使用一個_遞迴_策略。如果你歸併的分支超過兩個,Git採用_章魚_策略。這些策略是自動選擇的,因為遞迴策略可以處理複雜的三路歸併情況——比如多於一個共同祖先的——但是它只能處理兩個分支的歸併。章魚歸併可以處理多個分支但是但必須更加小心以避免衝突帶來的麻煩,因此它被選中作為歸併兩個以上分支的預設策略。

實際上,你也可以選擇其他策略。其中的一個就是_子樹_歸併,你可以用它來處理子專案問題。這裡你會看到如何換用子樹歸併的方法來實現前一節裡所做的 rack 的嵌入。

子樹歸併的思想是你擁有兩個工程,其中一個專案對映到另外一個專案的子目錄中,反過來也一樣。當你指定一個子樹歸併,Git可以聰明地探知其中一個是另外一個的子樹從而實現正確的歸併——這相當神奇。

首先你將 Rack 應用加入到專案中。你將 Rack 專案當作你專案中的一個遠端引用,然後將它檢出到它自身的分支:

現在在你的rack_branch分支中就有了Rack專案的根目錄,而你自己的專案在master分支中。如果你先檢出其中一個然後另外一個,你會看到它們有不同的專案根目錄:

要將 Rack 專案當作子目錄拉取到你的master專案中。你可以在 Git 中用git read-tree來實現。你會在第9章學到更多與read-tree和它的朋友相關的東西,當前你會知道它讀取一個分支的根目錄樹到當前的暫存區和工作目錄。你只要切換回你的master分支,然後拉取rack分支到你主專案的master分支的rack子目錄:

當你提交的時候,看起來就像你在那個子目錄下擁有Rack的檔案——就像你從一個tarball裡拷貝的一樣。有意思的是你可以比較容易地歸併其中一個分支的變更到另外一個。因此,如果 Rack 專案更新了,你可以通過切換到那個分支並執行拉取來獲得上游的變更:

然後,你可以將那些變更歸併回你的 master 分支。你可以使用git merge -s subtree,它會工作的很好;但是 Git 同時會把歷史歸併到一起,這可能不是你想要的。為了拉取變更並預置提交說明,需要在-s subtree策略選項的同時使用--squash--no-commit選項。

所有 Rack 專案的變更都被歸併可以進行本地提交。你也可以做相反的事情——在你主分支的rack目錄裡進行變更然後歸併回rack_branch分支,然後將它們提交給維護者或者推送到上游。

為了得到rack子目錄和你rack_branch分支的區別——以決定你是否需要歸併它們——你不能使用一般的diff命令。而是對你想比較的分支執行git diff-tree

或者,為了比較你的rack子目錄和伺服器上你拉取時的master分支,你可以執行

6.8  總結

你已經看到了很多高階的工具,允許你更加精確地操控你的提交和暫存區。當你碰到問題時,你應該可以很容易找出是哪個分支什麼時候由誰引入了它們。如果你想在專案中使用子專案,你也已經學會了一些方法來滿足這些需求。到此,你應該能夠完成日常裡你需要用命令列在 Git 下做的大部分事情,並且感到比較順手。

相關文章