馬蹄疾 | 2018(農曆年)封山之作,和我一起嚼爛Git(兩萬字長文)

馬蹄疾發表於2019-01-08

本文是『horseshoe·Git專題』系列文章之一,後續會有更多專題推出

GitHub地址(持續更新):github.com/veedrin/hor…

部落格地址(文章排版真的很漂亮):veedrin.com

如果覺得對你有幫助,歡迎來GitHub點Star或者來我的部落格親口告訴我

我剛開始接觸git的時候,完全搞不清楚為什麼這個操作要用這個命令,而那個操作要用那個命令。

因為git不是一套注重使用者體驗的工具,git有自己的哲學。你首先要理解它的哲學,才能真正理解它是如何運作的。

我也是看了前輩寫的文章才在某一刻醍醐灌頂。

git有多強大,想必大家都有所耳聞。git有多令人困惑,想必大家也親身經歷過吧。

總而言之,學習git有兩板斧:其一,理解git的哲學;其二,在複雜實踐中積累處理問題的經驗。缺一不可。

這篇文章就是第一板斧。

作者我自己也還在路上,畢竟,這篇文章也只是我的學習心得,仍然需要大量的實踐。

寫git有多個角度,反覆權衡,我最終還是決定從命令的角度鋪陳,閱讀體驗也不至於割裂。

由於超過兩萬字數限制,掘金無法釋出完整版,想閱讀完整版請移步我的GitHub或者個人部落格

困難年歲,共勉。

01) add

git是一個資料庫系統,git是一個內容定址檔案系統,git是一個版本管理系統。

沒錯,它都是。

不過我們不糾結於git是什麼,我們單刀直入,介紹git命令。

要將未跟蹤的檔案和已跟蹤檔案的改動加入暫存區,我們可以使用git add命令。

不過很多人嫌git add命令不夠語義化,畢竟這一步操作是加入暫存區呀。所以git又增加了另外一個命令git stage,它們的效果是一模一樣的。

git倉庫、工作區和暫存區

進入主題之前,我們先要介紹一下git倉庫、工作區和暫存區的概念。

git倉庫

所謂的git倉庫就是一個有.git目錄的資料夾。它是和git有關的一切故事開始的地方。

可以使用git init命令初始化一個git倉庫。

$ git init
複製程式碼

也可以使用git clone命令從伺服器上克隆倉庫到本地。

$ git clone git@github.com:veedrin/horseshoe.git
複製程式碼

然後你的本地就有了一個和伺服器上一模一樣的git倉庫。

這裡要說明的是,clone操作並不是將整個倉庫下載下來,而是隻下載.git目錄。因為關於git的一切祕密都在這個目錄裡面,只要有了它,git就能復原到倉庫的任意版本。

工作區(working directory)

工作區,又叫工作目錄,就是不包括.git目錄的專案根目錄。我們要在這個目錄下進行手頭的工作,它就是版本管理的素材庫。你甚至可以稱任何與工作有關的目錄為工作區,只不過沒有.git目錄git是不認的。

暫存區(stage或者index)

stage在英文中除了有舞臺、階段之意外,還有作為動詞的準備、籌劃之意,所謂的暫存區就是一個為提交到版本庫做準備的地方。

那它為什麼又被稱作index呢?因為暫存區在物理上僅僅是.git目錄下的index二進位制檔案。它就是一個索引檔案,將工作區中的檔案和暫存區中的備份一一對應起來。

stage是表意的,index是表形的。

你可以把暫存區理解為一個豬豬儲錢罐。我們還是孩子的時候,手裡有一毛錢就會丟進儲錢罐裡。等到儲錢罐搖晃的聲音變的渾厚時,或者我們有一個心願急需用錢時,我們就砸開儲錢罐,一次性花完。

類比到軟體開發,每當我們寫完一個小模組,就可以將它放入暫存區。等到一個完整的功能開發完,我們就可以從暫存區一次性提交到版本庫裡。

這樣做的好處是明顯的:

  • 它可以實現更小顆粒度的撤銷。
  • 它可以實現批量提交到版本庫。

另外,新增到暫存區其實包含兩種操作。一種是將還未被git跟蹤過的檔案放入暫存區;一種是已經被git跟蹤的檔案,將有改動的內容放入暫存區。

放入暫存區

git預設是不會把工作區的檔案放入暫存區的。

$ git status

On branch master
No commits yet
Untracked files:
  (use "git add <file>..." to include in what will be committed)
    a.md
nothing added to commit but untracked files present (use "git add" to track)
複製程式碼

我們看到檔案現在被標註為Untracked files。表示git目前還無法追蹤它們的變化,也就是說它們還不在暫存區裡。

那麼我們如何手動將檔案或資料夾放入暫存區呢?

$ git add .
複製程式碼

上面的命令表示將工作目錄所有未放入暫存區的檔案都放入暫存區。這時檔案的狀態已經變成了Changes to be committed,表示檔案已經放入暫存區,等待下一步提交。每一次add操作其實就是為加入的檔案或內容生成一份備份。

下面的命令也能達到相同的效果。

$ git add -A
複製程式碼

假如我只想暫存單個檔案呢?後跟相對於當前目錄的檔名即可。

$ git add README.md
複製程式碼

暫存整個資料夾也是一樣的道理。因為git會遞迴暫存資料夾下的所有檔案。

$ git add src
複製程式碼

把從來沒有被標記過的檔案放入暫存區的命令是git add,暫存區中的檔案有改動也需要使用git add命令將改動放入暫存區。

這時狀態變成了Changes not staged for commit

$ git status

On branch master
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)
    modified:   a.md
no changes added to commit (use "git add" and/or "git commit -a")
複製程式碼

針對已經加入暫存區的檔案,要將檔案改動加入暫存區,還有一個命令。

$ git add -u
複製程式碼

它和git add -A命令的區別在於,它只能將已加入暫存區檔案的改動放入暫存區,而git add -A通吃兩種情況。

跟蹤內容

假設我們已經將檔案加入暫存區,現在我們往檔案中新增內容,再次放入暫存區,然後檢視狀態。

$ git status

On branch master
No commits yet
Changes to be committed:
  (use "git rm --cached <file>..." to unstage)
    new file:   a.md
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)
    modified:   a.md
複製程式碼

哎,突然變的有意思了。為什麼一個檔案會同時存在兩種狀態,它是薛定諤的貓麼?

想象一下,我想在一個檔案中先修復一個bug然後增加一個feather,我肯定希望分兩次放入暫存區,這樣可以實現顆粒度更細的撤銷和提交。但是如果git是基於檔案做版本管理的,它就無法做到。

所以git只能是基於內容做版本管理,而不是基於檔案。版本管理的最小單位叫做hunk,所謂的hunk就是一段連續的改動。一個檔案同時有兩種狀態也就不稀奇了。

objects

git專案的.git目錄下面有一個目錄objects,一開始這個目錄下面只有兩個空目錄:infopack

一旦我們執行了git add命令,objects目錄下面就會多出一些東西。

.git/
.git/objects/
.git/objects/e6/
.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391
複製程式碼

它多出了一個2個字元命名的目錄和一個38個字元命名的檔案。加起來正好是40個字元。增加一個2個字元的目錄是為了提高檢索效率。

SHA-1是一種雜湊加密演算法,它的特點是隻要加密的內容相同,得到的校驗和也相同。當然這種說法是不準確的,但是碰撞的概率極低。

git除了用內容來計算校驗和之外,還加入了一些其他資訊,目的也是為了進一步降低碰撞的概率。

重點是,SHA-1演算法是根據內容來計算校驗和的,跟前面講的git跟蹤內容相呼應。git被稱為一個內容定址檔案系統不是沒有道理的。

我們可以做個實驗。初始化本地倉庫兩次,每次都新建一個markdown檔案,裡面寫## git is awesome,記下完整的40個字元的校驗和,看看它們是否一樣。

.git/objects/56/46a656f6331e1b30988472fefd48686a99e10f
複製程式碼

如果你真的做了實驗,你會發現即便兩個檔案的檔名和檔案格式都不一樣,只要內容一樣,它們的校驗和就是一樣的,並且就是上面列出的校驗和。

現在大家應該對git跟蹤內容這句話有更深的理解了。

相同內容引用一個物件

雖然開發者要極力避免這種情況,但是如果一個倉庫有多個內容相同的檔案,git會如何處理呢?

我們初始化一個本地倉庫,新建兩個不同名的檔案,但檔案內容都是## git is awesome。執行git add .命令之後看看神祕的objects目錄下會發生什麼?

.git/objects/56/46a656f6331e1b30988472fefd48686a99e10f
複製程式碼

只有一個目錄,而且校驗和跟之前一模一樣。

其實大家肯定早就想到了,git這麼優秀的工具,怎麼可能會讓浪費磁碟空間的事情發生呢?既然多個檔案的內容相同,肯定只儲存一個物件,讓它們引用到這裡來就好了。

檔案改動對應新物件

現在我們猜測工作區的檔案和objects目錄中的物件是一一對應起來的。但事實真的是這樣嗎?

我們初始化一個本地倉庫,新建一個markdown檔案,執行git add .命令。現在objects目錄中已經有了一個物件。然後往檔案中新增內容## git is awesome。再次執行git add .命令。

.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391
.git/objects/56/46a656f6331e1b30988472fefd48686a99e10f
複製程式碼

哎,objects目錄中出現了兩個物件。第一個物件肯定對應空檔案。第二個物件我們太熟悉了,對應的是新增內容後的檔案。

再次強調,git是一個版本管理系統,檔案在它這裡不是主角,版本才是。剛才我們暫存了兩次,可以認為暫存區現在已經有了兩個版本(暫存區的版本實際上是內容備份,並不是真正的版本)。當然就需要兩個物件來儲存。

檔案改動全量儲存

初始化一個本地倉庫,往工作區新增lodash.js未壓縮版本,版本號是4.17.11,體積大約是540KB。執行git add .命令後objects目錄下面出現一個物件,體積大約是96KB

.git/objects/cb/139dd81ebee6f6ed5f5a9198471f5cdc876d70
複製程式碼

我們對lodash.js檔案內容作一個小小的改動,將版本號從4.17.11改為4.17.10,再次執行git add .命令。然後大家會驚奇的發現objects目錄下有兩個物件了。驚奇的不是這個,而是第二個物件的體積也是大約96KB

.git/objects/cb/139dd81ebee6f6ed5f5a9198471f5cdc876d70
.git/objects/bf/c087eec7e61f106df8f5149091b8790e6f3636
複製程式碼

明明只改了一個數字而已,第二個物件卻還是這麼大。

前面剛誇git會精打細算,怎麼到這裡就不知深淺了?這是因為多個檔案內容相同的情況,引用到同一個物件並不會造成查詢效率的降低,而暫存區的多個物件之間如果只儲存增量的話,版本之間的查詢和切換需要花費額外的時間,這樣做是不划算的。

但是全量儲存也不是個辦法吧。然而git魚和熊掌想兼得,它也做到了。後面會講到。

重新命名會拆分成刪除和新建兩個動作

初始化一個本地倉庫,新建一個檔案,執行git add .命令。然後重新命名該檔案,檢視狀態資訊。

$ git status

On branch master
No commits yet
Changes to be committed:
  (use "git rm --cached <file>..." to unstage)
    new file:   a.md
Changes not staged for commit:
  (use "git add/rm <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)
    deleted:    a.md
Untracked files:
  (use "git add <file>..." to include in what will be committed)
    b.md
複製程式碼

這是由於git的內部機制導致的。生成物件的時候,它發現倉庫中叫這個名字的檔案不見了,於是標記為已刪除,又發現有一個新的檔名是之前沒有標記過的,於是標記為未跟蹤。因為它只是重新命名而已,檔案內容並沒有改變,所以可以共享物件,並不會影響效率。

blob物件

git的一切祕密都在.git目錄裡。因為它擁有專案的完整資訊,所以git一定是把備份存在了某個地方。git把它們存在了哪裡,又是如何儲存它們的呢?

這些備份資訊,git統一稱它們為物件。git總共有四種物件型別,都存在.git/objects目錄下。

這一次我們只介紹blob物件。

它儲存檔案的內容和大小。當開發者把未跟蹤的檔案或跟蹤檔案的改動加入暫存區,就會生成若干blob物件。git會對blob物件進行zlib壓縮,以減少空間佔用。

因為它只儲存內容和大小,所以兩個檔案即便檔名和格式完全不一樣,只要內容相同,就可以共享一個blob物件。

注意blob物件和工作目錄的檔案並不是一一對應的,因為工作目錄的檔案幾乎會被多次新增到暫存區,這時一個檔案會對應多個blob物件。

index

倉庫的.git目錄下面有一個檔案,它就是大名鼎鼎的暫存區。

是的,暫存區並不是一塊區域,只是一個檔案,確切的說,是一個索引檔案。

它儲存了專案結構、檔名、時間戳以及blob物件的引用。

工作區的檔案和blob物件之間就是通過這個索引檔案關聯起來的。

打包

還記得我們在檔案改動全量儲存小節裡講到,git魚和熊掌想兼得麼?

又想全量儲存,不降低檢索和切換速度,又想盡可能壓榨體積。git是怎麼做到的呢?

git會定期或者在推送到遠端之前對git物件進行打包處理。

打包的時候儲存檔案最新的全量版本,基於該檔案的歷史版本的改動則只儲存diff資訊。因為開發者很少會切換到較早的版本中,所以這時候效率就可以部分犧牲。

需要注意的是,所有的git物件都會被打包,而不僅僅是blob物件。

git也有一個git gc命令可以手動執行打包。

$ git gc

Counting objects: 11, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (9/9), done.
Writing objects: 100% (11/11), done.
Total 11 (delta 3), reused 0 (delta 0)
複製程式碼

之前的git物件檔案都不見了,pack資料夾多了兩個檔案。其中 .pack 字尾檔案儲存的就是打包前git物件檔案的實際內容。

.git/objects/
.git/objects/info/
.git/objects/info/packs
.git/objects/pack/
.git/objects/pack/pack-99b4704a207ea3cc4924c9f0febb6ea45d4cdfd2.idx
.git/objects/pack/pack-99b4704a207ea3cc4924c9f0febb6ea45d4cdfd2.pack
複製程式碼

只能說,git gc的語義化不夠好。它的功能不僅僅是垃圾回收,還有打包。

02) commit

git是一個版本管理系統。它的終極目的就是將專案特定時間的資訊保留成一個版本,以便將來的回退和查閱。

我們已經介紹了暫存區,暫存區的下一步就是版本庫,而促成這一步操作的是git commit命令。

提交

暫存區有待提交內容的情況下,如果直接執行git commit命令,git會跳往預設編輯器要求你輸入提交說明,你也可以自定義要跳往的編輯器。


# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
# On branch master
# Initial commit
# Changes to be committed:
#   new file:   a.md
複製程式碼

提交之後我們就看到這樣的資訊。

[master (root-commit) 99558b4] commit for nothing
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 a.md
複製程式碼

如果我就是不寫提交說明呢?

Aborting commit due to empty commit message.
複製程式碼

看到沒有,提交資訊在git中時必填的。

如果提交說明不多,可以加引數-m直接在命令後面填寫提交說明。

$ git commit -m "commit for nothing"
複製程式碼

你甚至可以將加入暫存區和提交一併做了。

$ git commit -am "commit for nothing"
複製程式碼

但是要注意,和git add -u命令一樣,未跟蹤的檔案是無法提交上去的。

重寫提交

amend翻譯成中文是修改的意思。git commit --amend命令允許你修改最近的一次commit。

$ git log --oneline

8274473 (HEAD -> master) commit for nothing
複製程式碼

目前專案提交歷史中只有一個commit。我突然想起來這次提交中有一個筆誤,我把高圓圓寫成了高曉鬆(真的是筆誤)。但是呢,我又不想為了這個筆誤增加一個commit,畢竟它僅僅是一個小小的筆誤而已。最重要的是我想悄無聲息的改正它,以免被別人笑話。

這時我就可以使用git commit --amend命令。

首先修改高曉鬆高圓圓

然後執行git add a.md命令。

最後重寫提交。git會跳往預設或者自定義編輯器提示你修改commit說明。當然你也可以不改。

$ git commit --amend

commit for nothing
# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
# Date:      Thu Jan 3 09:33:56 2019 +0800
# On branch master
# Initial commit
# Changes to be committed:
#   new file:   a.md
複製程式碼

我們再來看提交歷史。

$ git log --oneline

8a71ae1 (HEAD -> master) commit for nothing
複製程式碼

提交歷史中同樣只有一個commit。但是注意喲,commit已經不是之前的那個commit了,它們的校驗和是不一樣的。這就是所謂的重寫。

tree物件和commit物件

commit操作涉及到兩個git物件。

第一是tree物件。

它儲存子目錄和子檔案的引用。如果只有blob物件,那版本庫將是一團散沙。正因為有tree物件將它們的關係登記在冊,才能構成一個有結構的版本庫。

新增到暫存區操作並不會生成tree物件,這時專案的結構資訊儲存在index檔案中,直到提交版本庫操作,才會為每一個目錄分別生成tree物件。

第二是commit物件。

它儲存每個提交的資訊,包括當前提交的根tree物件的引用,父commit物件的引用,作者和提交者,還有提交資訊。所謂的版本,其實指的就是這個commit物件。

作者和提交者通常是一個人,但也存在不同人的情況。

objects

初始化一個git專案,新建一些檔案和目錄。

src/
src/a.md
lib/
lib/b.md
複製程式碼

首先執行git add命令。我們清楚,這會在.git/objects目錄下生成一個blob物件,因為目前兩個檔案都是空檔案,共享一個blob物件。

.git/objects/info/
.git/objects/pack/
.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391
複製程式碼

現在我們執行git commit命令,看看有什麼變化。

.git/objects/info/
.git/objects/pack/
.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391
.git/objects/93/810bbde0f994d41ef550324a2c1ad5f9278e19
.git/objects/52/0c9f9f61657ca1e65a288ea77d229a27a8171b
.git/objects/0b/785fa11cd93f95b1cab8b9cbab188edc7e04df
.git/objects/49/11ff67189d8d5cc2f94904fdd398fc16410d56
複製程式碼

有意思。剛剛只有一個blob物件,怎麼突然蹦出來這麼多git物件呢?想一想之前說的commit操作涉及到兩個git物件這句話,有沒有可能多出來的幾個,分別是tree物件和commit物件?

我們使用git底層命令git cat-file -t <commit>檢視這些物件的型別發現,其中有一個blob物件,三個tree物件,一個commit物件。

這是第一個tree物件。

$ git cat-file -t 93810bb

tree
複製程式碼
$ git cat-file -p 93810bb

100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391    b.md
複製程式碼

這是第二個tree物件。

$ git cat-file -t 520c9f9

tree
複製程式碼
$ git cat-file -p 520c9f9

100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391    a.md
複製程式碼

這是第三個tree物件。

$ git cat-file -t 0b785fa

tree
複製程式碼
$ git cat-file -p 0b785fa

040000 tree 93810bbde0f994d41ef550324a2c1ad5f9278e19    lib
040000 tree 520c9f9f61657ca1e65a288ea77d229a27a8171b    src
複製程式碼

可以看到,提交時每個目錄都會生成對應的tree物件。

然後我們再來看commit物件。

$ git cat-file -t 4911ff6

commit
複製程式碼
$ git cat-file -p 4911ff6

tree 0b785fa11cd93f95b1cab8b9cbab188edc7e04df
parent c4731cfab38f036c04de93facf07cae496a124a2
author veedrin <veedrin@qq.com> 1546395770 +0800
committer veedrin <veedrin@qq.com> 1546395770 +0800
commit for nothing
複製程式碼

可以看到,commit會關聯根目錄的tree物件,因為關聯它就可以關聯到所有的專案結構資訊,所謂擒賊先擒王嘛。它也要關聯父commit,也就是它的上一個commit,這樣才能組成版本歷史。當然,如果是第一個commit那就沒有父commit了。然後就是commit說明和一些參與者資訊。

我們總結一下,git add命令會為加入暫存區的內容或檔案生成blob物件,git commit命令會為加入版本庫的內容或檔案生成tree物件和commit物件。至此,四種git物件我們見識了三種。

為啥不在git add的時候就生成tree物件呢?

所謂暫存區,就是不一定會儲存為版本的資訊,只是一個準備的臨時場所。git認為在git add的時候生成tree物件是不夠高效的,完全可以等版本定型時再生成。而版本定型之前的結構資訊存在index檔案中就好了。

03) branch

分支是使得git如此靈活的強大武器,正是因為有巧妙的分支設計,眾多的git工作流才成為可能。

現在我們已經知道commit物件其實就是git中的版本。那我們要在版本之間切換難道只能通過指定commit物件毫無意義的SHA-1值嗎?

當然不是。

在git中,我們可以通過將一些指標指向commit物件來方便操作,這些指標便是分支。

分支在git中是一個模稜兩可的概念。

你可以認為它僅僅是一個指標,指向一個commit物件節點。

你也可以認為它是指標指向的commit物件節點追溯到某個交叉節點之間的commit歷史。

嚴格的來說,一種叫分支指標,一種叫分支歷史。不過實際使用中,它們在名字上常常不作區分。

所以我們需要意會文字背後的意思,它究竟說的是分支指標還是分支歷史。

大多數時候,它指的都是分支指標。

master分支

剛剛初始化的git倉庫,會發現.git/refs/heads目錄下面是空的。這是因為目前版本庫裡還沒有任何commit物件,而分支一定是指向commit物件的。

一旦版本庫裡有了第一個commit物件,git都會在.git/refs/heads目錄下面自動生成一個master檔案,它就是git的預設分支。不過它並不特殊,只是它充當的是一個預設角色而已。

剛剛初始化的git倉庫會顯示目前在master分支上,其實這個master分支是假的,.git/refs/heads目錄下根本沒有這個檔案。只有等提交歷史不為空時才有會真正的預設分支。

我們看一下master檔案到底有什麼。

$ cat .git/refs/heads/master

6b5a94158cc141286ac98f30bb189b8a83d61347
複製程式碼

40個字元,明顯是某個git物件的引用。再識別一下它的型別,發現是一個commit物件。

$ git cat-file -t 6b5a941

commit
複製程式碼

就這麼簡單,所謂的分支(分支指標)就是一個指向某個commit物件的指標。

HEAD指標

形象的講,HEAD就是景區地圖上標註你當前在哪裡的一個圖示。

你當前在哪裡,HEAD就在哪裡。它一般指向某個分支,因為一般我們都會在某個分支之上。

因為HEAD是用來標註當前位置的,所以一旦HEAD的位置被改變,工作目錄就會切換到HEAD指向的分支。

$ git log --oneline

f53aaa7 (HEAD -> master) commit for nothing
複製程式碼

但是也有例外,比如我直接簽出到某個沒有分支引用的commit。

$ git log --oneline

cb64064 (HEAD -> master) commit for nothing again
324a3c0 commit for nothing
複製程式碼
$ git checkout 324a3c0

Note: checking out '324a3c0'.
You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by performing another checkout.
If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -b with the checkout command again. Example:
  git checkout -b <new-branch-name>
HEAD is now at 324a3c0... commit for nothing
複製程式碼
$ git log --oneline

324a3c0 commit for nothing
複製程式碼

這個時候的HEAD就叫做detached HEAD

要知道,只有在初始提交和某個分支之間的commit才是有效的。當你的HEAD處於detached HEAD狀態時,在它之上新建的commit沒有被任何分支包裹。一旦你切換到別的分支,這個commit(可能)再也不會被引用到,最終會被垃圾回收機制刪除。因此這是很危險的操作。

324a3c0 -- cb64064(master)
   \
 3899a24(HEAD)
複製程式碼

如果不小心這麼做了,要麼在原地新建一個分支,要麼將已有的分支強行移動過來。確保它不會被遺忘。

死亡不是終結,遺忘才是。——尋夢環遊記

建立

除了預設的master分支,我們可以隨意建立新的分支。

$ git branch dev
複製程式碼

一個dev分支就建立好了。

檢視

或許有時我們也想要檢視本地倉庫有多少個分支,因為在git中新建分支實在是太容易了。

$ git branch

  dev
* master
複製程式碼

當前分支的前面會有一個*號標註。

同時檢視本地分支和遠端分支引用,新增-a引數。

$ git branch -a

* master
  remotes/origin/HEAD -> origin/master
  remotes/origin/master
複製程式碼

刪除

一般分支合併完之後就不再需要了,這時就要將它刪除。

$ git branch -d dev

Deleted branch dev (was 657142d).
複製程式碼

有時候我們會得到不一樣的提示。

$ git branch -d dev

error: The branch 'dev' is not fully merged.
If you are sure you want to delete it, run 'git branch -D dev'.
複製程式碼

這是git的一種保護措施。is not fully merged是針對當前分支來說的,意思是你要刪除的分支還有內容沒有合併進當前分支,你確定要刪除它嗎?

大多數時候,當然是要的。

$ git branch -D dev

Deleted branch dev (was 657142d).
複製程式碼

-D--delete --force的縮寫,你也可以寫成-df

需要注意的是,刪除分支僅僅是刪除一個指標而已,並不會刪除對應的commit物件。不過有可能刪除分支以後,這一串commit物件就無法再被引用了,從而被垃圾回收機制刪除。

04) checkout

在git中,暫存區裡有若干備份,版本庫裡有若干版本。留著這些東西肯定是拿來用的對吧,怎麼用呢?當我需要哪一份的時候我就切換到哪一份。

git checkout命令就是用來幹這個的,官方術語叫做簽出

怎麼理解checkout這個詞呢?checkout原本指的是消費結束服務員要與你核對一下賬單,結完賬之後你就可以走了。在git中核對指的是diff,比較兩份版本的差異,如果發現沒有衝突那就可以切換過來了。

底層

我們知道HEAD指標指向當前版本,而git checkout命令的作用是切換版本,它們肯定有所關聯。

目前HEAD指標指向master分支。

$ cat .git/HEAD

ref: refs/heads/master
複製程式碼

如果我切換到另一個分支,會發生什麼?

$ git checkout dev

Switched to branch 'dev'
複製程式碼
$ cat .git/HEAD

ref: refs/heads/dev
複製程式碼

果然,git checkout命令的原理就是改變了HEAD指標。而一旦HEAD指標改變,git就會取出HEAD指標指向的版本作為當前工作目錄的版本。簽出到一個沒有分支引用的commit也是一樣的。

符號

在進入正題之前,我們要先聊聊git中的兩個符號~^

如果我們要從一個分支切換到另一個分支,那還好說,足夠語義化。但是如果我們要切換到某個commit,除了兢兢業業的找到它的SHA-1值,還有什麼辦法快速的引用到它呢?

比如說我們可以根據commit之間的譜系關係快速定位。

$ git log --graph --oneline

* 4e76510 (HEAD -> master) c4
*   2ec8374 c3
|\  
| * 7c0a8e3 c2
* | fb60f51 c1
|/  
* dc96a29 c0
複製程式碼

~的作用是在縱向上定位。它可以一直追溯到最早的祖先commit。如果commit歷史有分叉,那它就選第一個,也就是主幹上的那個。

^的作用是在橫向上定位。它無法向上追溯,但是如果commit歷史有分叉,它能定位所有分叉中的任意一支。

HEAD不加任何符號、加~0 符號或者加^0符號時,定位的都是當前版本

這個不用說,定位當前commit。

$ git rev-parse HEAD

4e76510fe8bb3c69de12068ab354ef37bba6da9d
複製程式碼

它表示定位第零代父commit,也就是當前commit。

$ git rev-parse HEAD~0

4e76510fe8bb3c69de12068ab354ef37bba6da9d
複製程式碼

它表示定位當前commit的第零個父commit,也就是當前commit。

$ git rev-parse HEAD^0

4e76510fe8bb3c69de12068ab354ef37bba6da9d
複製程式碼

~符號數量的堆砌或者~數量的寫法定位第幾代父commit

$ git rev-parse HEAD~~

fb60f519a59e9ceeef039f7efd2a8439aa7efd4b
複製程式碼
$ git rev-parse HEAD~2

fb60f519a59e9ceeef039f7efd2a8439aa7efd4b
複製程式碼

^數量的寫法定位第幾個父commit

注意,^定位的是當前基礎的父commit。

$ git rev-parse HEAD^

2ec837440051af433677f786e502d1f6cdeb0a4a
複製程式碼
$ git rev-parse HEAD^1

2ec837440051af433677f786e502d1f6cdeb0a4a
複製程式碼

因為當前commit只有一個父commit,所以定位第二個父commit會失敗。

$ git rev-parse HEAD^2

HEAD^2
fatal: ambiguous argument 'HEAD^2': unknown revision or path not in the working tree.
Use '--' to separate paths from revisions, like this:
'git <command> [<revision>...] -- [<file>...]'
複製程式碼

~數量^數量的寫法或者^數量^數量的寫法定位第幾代父commit的第幾個父commit

當前commit的第一代父commit的第零個父commit,意思就是第一代父commit咯。

$ git rev-parse HEAD~^0

2ec837440051af433677f786e502d1f6cdeb0a4a
複製程式碼

比如這裡定位的是當前commit的第一代父commit的第一個父commit。再次注意,^定位的是當前基礎的父commit。

$ git rev-parse HEAD~^1

fb60f519a59e9ceeef039f7efd2a8439aa7efd4b
複製程式碼

這裡定位的是當前commit的第一代父commit的第二個父commit。

$ git rev-parse HEAD~^2

7c0a8e3a325ce1b5a1cdeb8c89bef1ecf17c10c9
複製程式碼

同樣,定位到一個不存在的commit會失敗。

$ git rev-parse HEAD~^3

HEAD~^3
fatal: ambiguous argument 'HEAD~^3': unknown revision or path not in the working tree.
Use '--' to separate paths from revisions, like this:
'git <command> [<revision>...] -- [<file>...]'
複製程式碼

~不同,^2^^的效果是不一樣的。^2指的是第二個父commit,^^指的是第一個父commit的第一個父commit。

切換到HEAD

git checkout命令如果不帶任何引數,預設會加上HEAD引數。而HEAD指標指向的就是當前commit。所以它並不會有任何簽出動作。

前面沒有提到的是,git checkout命令會有一個順帶效果:比較簽出後的版本和暫存區之間的差異。

所以git checkout命令不帶任何引數,意思就是比較當前commit和暫存區之間的差異。

$ git checkout

A   b.md
複製程式碼
$ git checkout HEAD

A   b.md
複製程式碼

切換到commit

開發者用的最多的當然是切換分支。其實checkout後面不僅可以跟分支名,也可以跟commit的校驗和,還可以用符號定位commit。

$ git checkout dev

Switched to branch 'dev'
複製程式碼
$ git checkout acb71fe

Note: checking out 'acb71fe11f78d230b860692ea6648906153f3d27'.
You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by performing another checkout.
If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -b with the checkout command again. Example:
  git checkout -b <new-branch-name>
HEAD is now at acb71fe... null
複製程式碼
$ git checkout HEAD~2

Note: checking out 'acb71fe11f78d230b860692ea6648906153f3d27'.
You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by performing another checkout.
If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -b with the checkout command again. Example:
  git checkout -b <new-branch-name>
HEAD is now at acb71fe... null
複製程式碼

建立分支並切換

有時候我們在建立分支時希望同時切換到建立後的分支,僅僅git branch <branch>是做不到的。這時git checkout命令可以提供一個快捷操作,建立分支和切換分支一步到位。

$ git checkout -b dev

Switched to a new branch 'dev'
複製程式碼

暫存區檔案覆蓋工作區檔案

git checkout不僅可以執行切換commit這種全量切換,它還能以檔案為單位執行微觀切換。

$ git status

On branch master
No commits yet
Changes to be committed:
  (use "git rm --cached <file>..." to unstage)
    new file:   a.md
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)
    modified:   a.md
複製程式碼
$ git checkout -- a.md
複製程式碼
$ git status

On branch master
No commits yet
Changes to be committed:
  (use "git rm --cached <file>..." to unstage)
    new file:   a.md
複製程式碼

因為暫存區覆蓋了工作區,所以工作區的改動就被撤銷了,現在只剩下暫存區的改動等待提交。其實相當於撤銷檔案在工作區的改動,只不過它的語義是覆蓋。這個命令沒有任何提示,直接撤銷工作區改動,要謹慎使用。

我們看到git提示語中有一個git checkout -- <file>命令,這又是幹嘛用的呢?

提醒一下,這個引數的寫法不是git checkout --<file>,而是git checkout -- <file>

其實它和git checkout <file>的效果是一樣的。但是別急,我是說這兩個命令想要達到的效果是一樣的,但實際效果卻有略微的差別。

獨立的--引數在Linux命令列中指的是:視後面的引數為檔名。當後面跟的是檔名的時候,最好加上獨立的--引數,以免有歧義。

也就是說,如果該專案正好有一個分支名為a.md(皮一下也不是不行對吧),那加獨立的--引數就不會操作分支,而是操作檔案。

如果你覺得僅僅撤銷一個檔案在工作區的改動不過癮,你不是針對誰,你是覺得工作區的改動都是垃圾。那麼還有一個更危險的命令。

$ git checkout -- .
複製程式碼

.代表當前目錄下的所有檔案和子目錄。這條命令會撤銷所有工作區的改動。

當前commit檔案覆蓋暫存區檔案和工作區檔案

如果執行git checkout -- <file>的時候加上一個分支名或者commit的校驗和,效果就是該檔案的當前版本會同時覆蓋暫存區和工作區。相當於同時撤銷檔案在暫存區和工作區的改動。

$ git status

On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)
    modified:   a.md
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)
    modified:   a.md
複製程式碼
$ git checkout HEAD -- a.md
複製程式碼
$ git status

On branch master
nothing to commit, working tree clean
複製程式碼

最後再提醒一下,執行git checkout命令作用於檔案時,即便覆蓋內容與被覆蓋內容有衝突,也會直接覆蓋,所以這真的是悶聲打雷式的git命令,一定要抽自己幾個耳刮子方可放心食用。

05) merge

可以方便的建立分支是git如此受歡迎的重要原因,利用git checkout <branch>也讓開發者在分支之間穿梭自如。然而百川終入海,其他分支上完成的工作終究是要合併到主分支上去的。

所以我們來看看git中的合併操作。

首先說明,執行git merge命令之前需要一些準備工作。

$ git merge dev

error: Your local changes to the following files would be overwritten by merge:
    a.md
Please commit your changes or stash them before you merge.
Aborting
複製程式碼

合併操作之前必須保證暫存區內沒有待提交內容,否則git會阻止合併。這是因為合併之後,git會將合併後的版本覆蓋暫存區。所以會有丟失工作成果的危險。

至於工作區有待新增到暫存區的內容,git倒不會阻止你。可能git覺得它不重要吧。

不過最好還是保持一個乾淨的工作區再執行合併操作。

不同分支的合併

不同分支指的是要合併的兩個commit在某個祖先commit之後開始分叉。

C0 -- C1 -- C2(HEAD -> master)
       \
        C3(dev)
複製程式碼

git merge後跟合併客體,表示要將它合併進來。

$ git merge dev
複製程式碼

進行到這裡,如果沒有衝突,git會彈出預設或者自定義的編輯器,讓你填寫commit說明。當然它會給你填寫一個預設的commit說明。

Merge branch 'dev'

# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
複製程式碼

為什麼要你填寫commit說明?因為這種情況的git merge實際上會建立一個新的commit物件,記錄此次合併的資訊,並將當前分支指標移動到它上面來。

C0 -- C1 -- C2 -- C4(HEAD -> master)(merge commit)
       \          /
        \        /
          C3(dev)
複製程式碼

大家常說不同分支的git merge操作是一個三方合併,這裡的三方指的是合併主體commit合併客體commit以及合併主客體的共同祖先commit

所謂的三方和併到底是什麼意思呢?

git會提取出合併主體commit相對於合併主客體的共同祖先commit的diff與合併客體commit相對於合併主客體的共同祖先commit的diff,再去比較這兩份diff有沒有修改同一個地方,這裡同一個地方的單位是檔案的行。如果沒有,那就將這兩份diff合併生成一個新的commit,當前分支指標向右移。如果有那就要求開發者自行解決。

所以在三方合併中,合併主客體的共同祖先commit只是一個參照物。

合併主體在合併客體的上游

它指的是開發者當前在一個commit節點上,要將同一個分支上更新的commit節點合併進來。

C0 -- C1 -- C2(HEAD -> master) -- C3(dev)
複製程式碼

這時候會發生什麼呢?

這相當於更新當前分支指標,所以只需要將當前分支指標向下遊移動,讓合併主體與合併客體指向同一個commit即可。這時並不會產生一個新的commit。

用三方合併的概念來理解,合併主體commit合併主客體的共同祖先commit是同一個commit,合併主體commit相對於合併主客體的共同祖先commit的diff為空,合併客體commit相對於合併主客體的共同祖先commit的diff與空diff合併還是它自己,所以移動過去就行了,並不需要生成一個新的commit。

$ git merge dev

Updating 9242078..631ef3a
Fast-forward
 a.md | 2 ++
 1 file changed, 2 insertions(+)
複製程式碼
C0 -- C1 -- C2 -- C3(HEAD -> master, dev)
複製程式碼

這種操作在git中有一個專有名詞,叫Fast forward

比如說git pull的時候經常發生這種情況。通常因為遠端有更新的commit我們才需要執行git pull命令,這時遠端就是合併客體,本地就是合併主體,遠端的分支指標在下游,也會觸發Fast forward

合併主體在合併客體的下游

如果合併主體在合併客體的下游,那合併主體本身就包含合併客體,合併操作並不會產生任何效果。

C0 -- C1 -- C2(dev) -- C3(HEAD -> master)
複製程式碼
$ git merge dev

Already up to date.
複製程式碼
C0 -- C1 -- C2(dev) -- C3(HEAD -> master)
複製程式碼

依然用三方合併的概念來理解,這時合併客體commit合併主客體的共同祖先commit是同一個commit,合併客體commit相對於合併主客體的共同祖先commit的diff為空,合併主體commit相對於合併主客體的共同祖先commit的diff與空diff合併還是它自己。但是這回它都不用移動,因為合併後的diff就是它自己原有的diff。

注意,這時候dev分支指標會不會動呢?

當然不會,git merge操作對合並客體是沒有任何影響的。

同時合併多個客體

如果你在git merge後面跟不止一個分支,這意味著你想同時將它們合併進當前分支。

$ git merge aaa bbb ccc

Fast-forwarding to: aaa
Trying simple merge with bbb
Trying simple merge with ccc
Merge made by the 'octopus' strategy.
 aaa.md | 0
 bbb.md | 0
 ccc.md | 0
 3 files changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 aaa.md
 create mode 100644 bbb.md
 create mode 100644 ccc.md
複製程式碼

git合併有多種策略,上面使用的是'octopus' strategy章魚策略,因為同時合併的多個分支最終都會指向新的commit,看起來像章魚的觸手。

合併有衝突

git merge操作並不總是如此順利的。因為有時候要合併的兩個分支不是同一個人的,就會有很大的概率遇到兩人同時修改檔案某一行的情況。git不知道該用誰的版本,它認為兩個分支遇到了衝突。

這時就需要開發者手動的解決衝突,才能讓git繼續合併。

$ git merge dev

Auto-merging a.md
CONFLICT (content): Merge conflict in a.md
Automatic merge failed; fix conflicts and then commit the result.
複製程式碼

我們來看一下有衝突的檔案是什麼樣的。

<<<<<<< HEAD
apple
=======
banana
>>>>>>> dev
複製程式碼

執行git status命令。

$ git status

On branch master
You have unmerged paths.
  (fix conflicts and run "git commit")
  (use "git merge --abort" to abort the merge)
Unmerged paths:
  (use "git add <file>..." to mark resolution)
    both modified:   a.md
no changes added to commit (use "git add" and/or "git commit -a")
複製程式碼

解決完衝突之後,你需要再提交,告訴git可以完成合並了。

$ git commit -m "fix merge conflict"

U   a.md
error: Committing is not possible because you have unmerged files.
hint: Fix them up in the work tree, and then use 'git add/rm <file>'
hint: as appropriate to mark resolution and make a commit.
fatal: Exiting because of an unresolved conflict.
複製程式碼

誒,被拒絕了。是不是想起了自己的情場故事?

當我們解決衝突的時候,工作區已經有改動,所以需要先提交到暫存區。

$ git add a.md
複製程式碼
$ git commit -m "fix merge conflict"

[master 9b32d4d] fix merge conflict
複製程式碼

執行git add命令之後你也可以用git merge --continue來替代git commit命令。它會讓後面的行為跟沒有衝突時的行為表現的一樣。

如果你遇到衝突以後不知道如何解決,因為你要去詢問你的合作伙伴為什麼這樣改。這時你肯定想回到合併以前的狀態。

這對git來說很容易。只需要執行git merge --abort命令即可。

$ git merge --abort
複製程式碼

該命令無法保證恢復工作區的修改,所以最好是在合併之前先讓工作區保持乾淨。

06) rebase

git merge命令會生成一個新的合併commit。如果你有強迫症,不喜歡這個新的合併commit,git也有更加清爽的方案可以滿足你,它就是git rebase命令。

git就是哆啦A夢的口袋。

rebase翻譯過來是變基。意思就是將所有要合併進來的commit在新的基礎上重新提交一次。

基礎用法

git rebase <branch>會計算當前分支和目標分支的最近共同祖先,然後將最近共同祖先與當前分支之間的所有commit都變基到目標分支上,使得提交歷史變成一條直線。

C0 -- C1 -- C2 -- C3(master)
       \
        C4 -- C5 -- C6(HEAD -> dev)
複製程式碼

mergerebase後跟的分支名是不一樣的。合併是合併進來,變基是變基過去,你們感受一下。

$ git rebase master

First, rewinding head to replay your work on top of it...
Applying: C4.md
Applying: C5.md
Applying: C6.md
複製程式碼
C0 -- C1 -- C2 -- C3(master) -- C4' -- C5' -- C6'(HEAD -> dev)
       \
        C4 -- C5 -- C6
複製程式碼

現在最近共同祖先與當前分支之間的所有commit都被複制到master分支之後,並且將HEAD指標與當前分支指標切換過去。這招移花接木玩的很溜啊,如果你置身其中根本分不出區別。

原來的commit還在嗎?還在,如果你記得它的commit校驗和,仍然可以切換過去,git會提示你當前處於detached HEAD狀態下。只不過沒有任何分支指標指向它們,它們已經被拋棄了,剩餘的時光就是等待git垃圾回收命令清理它們。

好在,還有人記得它們,不是麼?

git rebase完並沒有結束,因為我變基的目標分支是master,而當前分支是dev。我需要切換到master分支上,然後再合併一次。

$ git checkout master
複製程式碼
$ git merge dev
複製程式碼

誒,說來說去,還是要合併啊?

別急,這種合併是Fast forward的,並不會生成一個新的合併commit。

如果我要變基的本體分支不是當前分支行不行?也是可以的。

$ git rebase master dev
複製程式碼

你在任何一個分支上,這種寫法都可以將dev分支變基到master分支上,變基完成當前分支會變成dev分支。

裁剪commit變基

變基有點像基因編輯,git有更精確的工具達到你想要的效果。

有了精確的基因編輯技術,媽媽再也不用擔心你長的啦。

C0 -- C1 -- C2 -- C3(master)
       \
        C4 -- C5 -- C6(dev)
         \
          C7 -- C8(HEAD -> hotfix)
複製程式碼
$ git rebase --onto master dev hotfix

First, rewinding head to replay your work on top of it...
Applying: C7.md
Applying: C8.md
複製程式碼
C0 -- C1 -- C2 -- C3(master) -- C7' -- C8'(HEAD -> hotfix)
       \
        C4 -- C5 -- C6(dev)
         \
          C7 -- C8
複製程式碼

--onto引數就是那把基因編輯的剪刀。

它會把hotfix分支hotfix分支與dev分支的最近共同祖先之間的commit裁剪下來,複製到目標基礎點上。注意,所謂的之間指的都是不包括最近共同祖先commit的範圍,比如這裡就不會複製C4commit。

$ git rebase --onto master dev

First, rewinding head to replay your work on top of it...
Applying: C7.md
Applying: C8.md
複製程式碼

如果--onto後面只寫兩個分支(或者commit)名,第三個分支(或者commit)預設就是HEAD指標指向的分支(或者commit)。

變基衝突解決

變基也會存在衝突的情況,我們看看衝突怎麼解決。

C0 -- C1 -- C2(HEAD -> master)
       \
        C3 -- C4(dev)
複製程式碼
$ git rebase master dev

First, rewinding head to replay your work on top of it...
Applying: c.md
Applying: a.md add banana
Using index info to reconstruct a base tree...
M   a.md
Falling back to patching base and 3-way merge...
Auto-merging a.md
CONFLICT (content): Merge conflict in a.md
error: Failed to merge in the changes.
Patch failed at 0002 a.md dev
The copy of the patch that failed is found in: .git/rebase-apply/patch
Resolve all conflicts manually, mark them as resolved with
"git add/rm <conflicted_files>", then run "git rebase --continue".
You can instead skip this commit: run "git rebase --skip".
To abort and get back to the state before "git rebase", run "git rebase --abort".
複製程式碼

C2和C4同時修改了a.md的某一行,引發衝突。git已經給我們提示了,大體上和merge的操作一致。

我們可以手動解決衝突,然後執行git addgit rebase --continue來完成變基。

如果你不想覆蓋目標commit的內容,也可以跳過這個commit,執行git rebase --skip。但是注意,這會跳過有衝突的整個commit,而不僅僅是有衝突的部分。

後悔藥也是有的,執行git rebase --abort,乾脆就放棄變基了。

cherry-pick

git rebase --onto命令可以裁剪分支以變基到另一個分支上。但它依然是挑選連續的一段commit,只是允許你指定頭和尾罷了。

別急,git cherry-pick命令雖然是一個獨立的git命令,它的效果卻還是變基,而且是commit級別的變基。

git cherry-pick命令可以挑選任意commit變基到目標commit上。你負責挑,它負責基。

用法

只需要在git cherry-pick命令後跟commit校驗和,就可以將它應用到目標commit上。

C0 -- C1 -- C2(HEAD -> master)
       \
        C3 -- C4 -- C5(dev)
               \
                C6 -- C7(hotfix)
複製程式碼

將當前分支切換到master分支。

$ git cherry-pick C6

[master dc342e0] c6
 Date: Mon Dec 24 09:13:57 2018 +0800
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 c6.md
複製程式碼
C0 -- C1 -- C2 -- C6'(HEAD -> master)
       \
        C3 -- C4 -- C5(dev)
               \
                C6 -- C7(hotfix)
複製程式碼

C6commit就按原樣重新提交到master分支上了。cherry-pick並不會修改原有的commit。

同時挑選多個commit也很方便,往後面疊加就行。

$ git cherry-pick C4 C7

[master ab1e7c7] c4
 Date: Mon Dec 24 09:12:58 2018 +0800
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 c4.md
[master 161d993] c7
 Date: Mon Dec 24 09:14:12 2018 +0800
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 c7.md
複製程式碼
C0 -- C1 -- C2 -- C4' -- C7'(HEAD -> master)
       \
        C3 -- C4 -- C5(dev)
               \
                C6 -- C7(hotfix)
複製程式碼

如果這多個commit正好是連續的呢?

$ git cherry-pick C3...C7

[master d16c42e] c4
 Date: Mon Dec 24 09:12:58 2018 +0800
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 c4.md
[master d16c42e] c6
 Date: Mon Dec 24 09:13:57 2018 +0800
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 c6.md
[master a4d5976] c7
 Date: Mon Dec 24 09:14:12 2018 +0800
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 c7.md
複製程式碼
C0 -- C1 -- C2 -- C4' -- C6' -- C7'(HEAD -> master)
       \
        C3 -- C4 -- C5(dev)
               \
                C6 -- C7(hotfix)
複製程式碼

需要注意,git所謂的從某某開始,一般都是不包括某某的,這裡也一樣。

有沒有發現操作連續commit的git cherry-pickgit rebase的功能已經非常接近了?所以呀,git cherry-pick也是變基,只不過一邊變基一邊喂櫻桃給你吃。

衝突

git各種命令解決衝突的方法都大同小異。

C0 -- C1(HEAD -> master)
 \
  C2(dev)
複製程式碼
$ git cherry-pick C2

error: could not apply 051c24c... banana
hint: after resolving the conflicts, mark the corrected paths
hint: with 'git add <paths>' or 'git rm <paths>'
hint: and commit the result with 'git commit'
複製程式碼

手動解決衝突,執行git add命令然後執行git cherry-pick --continue命令。

如果被唬住了想還原,執行git cherry-pick --abort即可。

變基還是合併

這是一個哲學問題。

有一種觀點認為,倉庫的commit歷史應該記錄實際發生過什麼。所以如果你將一個分支合併進另一個分支,commit歷史中就應該有這一次合併的痕跡,因為它是實實在在發生過的。

另一種觀點則認為,倉庫的commit歷史應該記錄專案過程中發生過什麼。合併不是專案開發本身帶來的,它是一種額外的操作,會使commit歷史變的冗長。

我是一個極簡主義者,所以我支援首選變基。

07) reset

git checkout命令可以在版本之間隨意切換,它的本質是移動HEAD指標。

那git有沒有辦法移動分支指標呢?

當然有,這就是git reset命令。

底層

git reset命令與git checkout命令的區別在於,它會把HEAD指標和分支指標一起移動,如果HEAD指標指向的是一個分支指標的話。

我們前面說過使用git checkout命令從有分支指向的commit切換到一個沒有分支指向的commit上,這個時候的HEAD指標被稱為detached HEAD。這是非常危險的。

C0 -- C1 -- C2(HEAD -> master)
複製程式碼
$ git checkout C1
複製程式碼
C0 -- C1(HEAD) -- C2(master)
複製程式碼

但是git reset命令沒有這個問題,因為它會把當前的分支指標也帶過去。

C0 -- C1 -- C2(HEAD -> master)
複製程式碼
$ git reset C1
複製程式碼
C0 -- C1(HEAD -> master) -- C2
複製程式碼

這就是重置的含義所在。它可以重置分支。

看另一種情況。如果是從一個沒有分支指向的commit切換到另一個沒有分支指向的commit上,那它們就是兩個韓國妹子,傻傻分不清楚了。

這是git checkout命令的效果。

C0 -- C1 -- C2(HEAD) -- C3(master)
複製程式碼
$ git checkout C1
複製程式碼
C0 -- C1(HEAD) -- C2 -- C3(master)
複製程式碼

這是git reset命令的效果。

C0 -- C1 -- C2(HEAD) -- C3(master)
複製程式碼
$ git reset C1
複製程式碼
C0 -- C1(HEAD) -- C2 -- C3(master)
複製程式碼

同時重置暫存區和工作區的改動

當你在 git reset 命令後面加 --hard 引數時,暫存區和工作區的內容都會重置為重置後的commit內容。也就是說暫存區和工作區的改動都會清空,相當於撤銷暫存區和工作區的改動。

而且是沒有確認操作的喲。

$ git status

On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)
    modified:   a.md
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)
    modified:   a.md
複製程式碼
$ git reset --hard HEAD^

HEAD is now at 58b0040 commit for nothing
複製程式碼
$ git status

On branch master
nothing to commit, working tree clean
複製程式碼

僅重置暫存區的改動

git reset 命令後面加 --mixed 引數,或者不加引數,因為--mixed引數是預設值,暫存區的內容會重置為重置後的commit內容,工作區的改動不會清空,相當於撤銷暫存區的改動。

同樣也是沒有確認操作的喲。

$ git status

On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)
    modified:   a.md
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)
    modified:   a.md
複製程式碼
$ git reset HEAD^

Unstaged changes after reset:
M   a.md
複製程式碼
$ git status

On branch master
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)
    modified:   a.md
no changes added to commit (use "git add" and/or "git commit -a")
複製程式碼

打個趣,如果git reset命令什麼都不加會怎樣呢?

你可以腦補一下,git reset命令不加引數預設就是--mixed,不加操作物件預設就是HEAD,所以單純的git reset命令相當於git reset --mixed HEAD命令。

那這又意味著什麼呢?

這意味著從當前commit重置到當前commit,沒有變化對吧?但是--mixed引數會撤銷暫存區的改動對不對,這就是它的效果。

同時保留暫存區和工作區的改動

如果 git reset 命令後面加 --soft 引數,鋼鐵直男的溫柔,你懂的。僅僅是重置commit而已,暫存區和工作區的改動都會保留下來。

更溫柔的是,重置前的commit內容與重置後的commit內容的diff也會放入暫存區。

$ git status

On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)
    modified:   a.md
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)
    modified:   a.md
複製程式碼
$ git diff --staged

diff --git a/a.md b/a.md
index 4a77268..fde8dcd 100644
--- a/a.md
+++ b/a.md
@@ -1,2 +1,3 @@
 apple
 banana
+cherry
複製程式碼
$ git reset --soft HEAD^
複製程式碼
$ git status

On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)
    modified:   a.md
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)
    modified:   a.md
複製程式碼
$ git diff --staged

diff --git a/a.md b/a.md
index 4a77268..fde8dcd 100644
--- a/a.md
+++ b/a.md
@@ -1 +1,3 @@
 apple
+banana
+cherry
複製程式碼

banana就是重置前的commit內容與重置後的commit內容的diff,可以看到,它已經在暫存區了。

檔案暫存區內容撤回工作區

git reset命令後面也可以跟檔名,它的作用是將暫存區的內容重置為工作區的內容,是git add -- <file>的反向操作。

git reset -- <file>命令是git reset HEAD --mixed -- <file>的簡寫。在操作檔案時,引數只有預設的--mixed一種。

它並不會撤銷工作區原有的改動。

$ git status

On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)
    modified:   a.md
複製程式碼
$ git reset -- a.md

Unstaged changes after reset:
M   a.md
複製程式碼
$ git status

On branch master
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)
    modified:   a.md
no changes added to commit (use "git add" and/or "git commit -a")
複製程式碼

git checkout命令後面也可以跟檔名,它的作用是撤銷工作區的改動,需要注意區分。

檔案若干commit版本撤回工作區

如果git reset命令後跟一個commit校驗和,它會把該commit與所有後代commit的diff重置到工作區。

意思就是將該檔案重置回你指定的commit版本,但是在你指定的commit之後的改動我也給你留著,就放到工作區裡吧。

$ git diff --staged

# 空
複製程式碼
git reset HEAD~4 -- a.md

Unstaged changes after reset:
M   a.md
複製程式碼
$ git diff --staged

diff --git a/a.md b/a.md
index 6f195b4..72943a1 100644
--- a/a.md
+++ b/a.md
@@ -1,5 +1 @@
 aaa
-bbb
-ccc
-ddd
-eee
複製程式碼

git diff --staged命令比較工作區和暫存區的內容。可以看到初始工作區和暫存區是一致的,重置檔案到4個版本之前,發現工作區比暫存區多了很多改動,這些都是指定commit之後的提交被重置到工作區了。

08) revert

有時候我們想撤回一個commit,但是這個commit已經在公共的分支上。如果直接修改分支歷史,可能會引起一些不必要的混亂。這個時候,git revert命令就派上用場了。

revert翻譯成中文是還原。我覺得稱它為對衝更合理。對衝指的是同時進行兩筆行情相關、方向相反、數量相當、盈虧相抵的交易,這麼理解git revert命令一針見血。

因為它的作用就是生成一個新的、完全相反的commit。

命令

git revert後跟你想要對衝的commit即可。

$ git revert HEAD

Revert "add c.md"
This reverts commit 8a23dad059b60ba847a621b6058fb32fa531b20a.
# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
# On branch master
# Changes to be committed:
#   deleted:    c.md
複製程式碼

git會彈出預設或者自定義的編輯器要求你輸入commit資訊。然後一個新的commit就生成了。

[master a8c4205] Revert "add c.md"
 1 file changed, 0 insertions(+), 0 deletions(-)
 delete mode 100644 c.md
複製程式碼

可以看到,原本我新增了一個檔案a.mdrevert操作就會執行刪除命令。在工作目錄看起來就像新增檔案操作被撤銷了一樣,其實是被對衝了。

它不會改變commit歷史,只會增加一個新的對衝commit。這是它最大的優點。

衝突

反向操作也會有衝突?你逗我的吧。

如果你操作的是最新的commit,那當然不會有衝突了。

那要操作的是以前的commit呢?

C0 -- C1 -- C2(HEAD -> master)
複製程式碼

比如a.mdC0內容為空,C1修改檔案內容為appleC2修改檔案內容為banana。這時候你想撤銷C1的修改。

$ git revert HEAD~

error: could not revert 483b537... apple
hint: after resolving the conflicts, mark the corrected paths
hint: with 'git add <paths>' or 'git rm <paths>'
hint: and commit the result with 'git commit'
複製程式碼

我們看一下檔案內容。

<<<<<<< HEAD
banana
=======
>>>>>>> parent of 483b537... apple
複製程式碼

手動解決衝突,執行git add命令然後執行git revert --continue命令完成對衝操作。

取消revert操作只需要執行git revert --abort即可。

09) stash

你在一個分支上開展了一半的工作,突然有一件急事要你去處理。這時候你得切換到一個新的分支,可是手頭上的工作你又不想立即提交。

這種場景就需要用到git的儲藏功能。

儲藏

想要儲藏手頭的工作,只需執行git stash命令。

$ git status

On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)
    modified:   a.md
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)
    modified:   b.md
Untracked files:
  (use "git add <file>..." to include in what will be committed)
    c.md
複製程式碼
$ git stash

Saved working directory and index state WIP on master: 974a2f2 update
複製程式碼

WIPwork in progress的縮寫,指的是進行中的工作。

$ git status

On branch master
Untracked files:
  (use "git add <file>..." to include in what will be committed)
    c.md
nothing added to commit but untracked files present (use "git add" to track)
複製程式碼

可以看到,除了未被git跟蹤的檔案之外,工作區和暫存區的內容都會被儲藏起來。現在你可以切換到其他分支進行下一步工作了。

檢視

我們看一下儲藏列表。

$ git stash list

stash@{0}: WIP on master: 974a2f2 apple
stash@{1}: WIP on master: c27b351 banana
複製程式碼

恢復

等我們完成其他工作,肯定要回到這裡,繼續進行中斷的任務。

$ git stash apply

On branch master
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)
    modified:   a.md
    modified:   b.md
Untracked files:
  (use "git add <file>..." to include in what will be committed)
    c.md
no changes added to commit (use "git add" and/or "git commit -a")
複製程式碼

誒,等等。怎麼a.md的變更也跑到工作區了?是的,git stash預設會將暫存區和工作區的儲藏全部恢復到工作區。如果我就是想原樣恢復呢?

$ git stash apply --index

On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)
    modified:   a.md
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)
    modified:   b.md
Untracked files:
  (use "git add <file>..." to include in what will be committed)
    c.md
複製程式碼

加一個引數--index就會讓工作區的歸工作區,讓暫存區的歸暫存區。

還有一點需要注意,恢復儲藏的操作可以應用在任何分支,它也不關心即將恢復儲藏的分支上,工作區和暫存區是否乾淨。如果有衝突,自行解決就是了。

我們瀏覽過儲藏列表,說明git stash apply僅僅是恢復了最新的那一次儲藏。

$ git stash apply stash@{1}
複製程式碼

指定儲藏的名字,我們就可以恢復列表中的任意儲藏了。

這個時候我們再看一下儲藏列表。

$ git stash list

stash@{0}: WIP on master: 974a2f2 apple
stash@{1}: WIP on master: c27b351 banana
複製程式碼

誒,發現還是兩條。我不是已經恢復了一條麼?

apply這個詞很巧妙,它只是應用,它可不會清理。

清理

想要清理儲藏列表,我們們得顯式的執行git stash drop命令。

$ git stash drop stash@{1}
複製程式碼
$ git stash list

stash@{0}: WIP on master: 974a2f2 apple
複製程式碼

現在就真的沒有了。希望你沒有喝酒?。

git還給我們提供了一個快捷操作,執行git stash pop命令,同時恢復儲藏和清理儲藏。

$ git stash pop
複製程式碼

10) view

有四個git命令可以用來檢視git倉庫相關資訊。

status

git status命令的作用是同時展示工作區和暫存區的diff、暫存區和當前版本的diff、以及沒有被git追蹤的檔案。

$ git status

On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)
    modified:   a.md
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)
    modified:   b.md
Untracked files:
  (use "git add <file>..." to include in what will be committed)
    c.md
複製程式碼

這個命令應該是最常用的git命令之一了,每次提交之前都要看一下。

git status -v命令相當於git status命令和git diff --staged之和。

$ git status -v

On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)
    modified:   a.md
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)
    modified:   b.md
Untracked files:
  (use "git add <file>..." to include in what will be committed)
    c.md
diff --git a/a.md b/a.md
index 5646a65..4c479de 100644
--- a/a.md
+++ b/a.md
@@ -1 +1 @@
-apple
+banana
複製程式碼

git status -vv命令相當於git status命令和git diff之和。

$ git status -vv

On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)
    modified:   a.md
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)
    modified:   b.md
Untracked files:
  (use "git add <file>..." to include in what will be committed)
    c.md
Changes to be committed:
diff --git c/a.md i/a.md
index 5646a65..4c479de 100644
--- c/a.md
+++ i/a.md
@@ -1 +1 @@
-apple
+banana
--------------------------------------------------
Changes not staged for commit:
diff --git i/b.md w/b.md
index e69de29..637a09b 100644
--- i/b.md
+++ w/b.md
@@ -0,0 +1 @@
+## git is awesome
複製程式碼

還有一個-s引數,給出的結果很有意思。

$ git status -s

M  a.md
 M b.md
?? c.md
複製程式碼

注意看,前面的字母位置是不一樣的。

第一個位置是該檔案在暫存區的狀態,第二個位置是該檔案在工作區的狀態。比如,以下資訊顯示a.md檔案在暫存區有改動待提交,在工作區也有改動待暫存。

MM a.md
複製程式碼

縮寫的狀態碼主要有這麼幾種:

狀態碼 含義
M 檔案內容有改動
A 檔案被新增
D 檔案被刪除
R 檔案被重新命名
C 檔案被複制
U 檔案衝突未解決
? 檔案未被git追蹤
! 檔案被git忽略

?!所代表的狀態因為沒有進入git版本系統,所以任何時候兩個位置都是一樣的。就像??或者!!這樣。

show

git show命令show的是什麼呢?git物件。

$ git show

commit 2bd3c9d7de54cec10f0896db9af04c90a41a8160
Author: veedrin <veedrin@qq.com>
Date:   Fri Dec 28 11:23:27 2018 +0800
    update
diff --git a/README.md b/README.md
index e8ab145..75625ce 100644
--- a/README.md
+++ b/README.md
@@ -5,3 +5,5 @@ one
 two
 three
+
+four
複製程式碼

git show相當於git show HEAD,顯示當前HEAD指向的commit物件的資訊。

當然,你也可以檢視某個git物件的資訊,後面跟上git物件的校驗和就行。

$ git show 38728d8

tree 38728d8
README.md
複製程式碼

diff

git diff命令可以顯示兩個主體之間的差異。

工作區與暫存區的差異

單純的git diff命令顯示工作區與暫存區之間的差異。

$ git diff

diff --git a/a.md b/a.md
index e69de29..5646a65 100644
--- a/a.md
+++ b/a.md
@@ -0,0 +1 @@
+## git is awesome
複製程式碼

因為是兩個主體之間的比較,git永遠將兩個主體分別命名為ab

也可以只檢視某個檔案的diff。當然這裡依然是工作區與暫存區之間的差異。

$ git diff a.md
複製程式碼

暫存區與當前commit的差異

git diff --staged命令顯示暫存區與當前commit的差異。

git diff --cached也可以達到相同的效果,它比較老,不如--staged語義化。

$ git diff --staged

diff --git a/b.md b/b.md
index e69de29..4c479de 100644
--- a/b.md
+++ b/b.md
@@ -0,0 +1 @@
+apple
複製程式碼

同樣,顯示某個檔案暫存區與當前commit的差異。

$ git diff --staged a.md
複製程式碼

兩個commit之間的差異

我們還可以用git diff檢視兩個commit之間的差異。

$ git diff C1 C2

diff --git a/a.md b/a.md
index e69de29..5646a65 100644
--- a/a.md
+++ b/a.md
@@ -0,0 +1 @@
+## git is awesome
diff --git a/b.md b/b.md
new file mode 100644
index 0000000..e69de29
複製程式碼

注意先後順序很重要,假如我改一下順序。

$ git diff C2 C1

diff --git a/a.md b/a.md
index 5646a65..e69de29 100644
--- a/a.md
+++ b/a.md
@@ -1 +0,0 @@
-## git is awesome
diff --git a/b.md b/b.md
deleted file mode 100644
index e69de29..0000000
複製程式碼

比較兩個commit之間某個檔案的差異。

$ git diff C1:a.md C2:a.md

diff --git a/a.md b/a.md
index e69de29..5646a65 100644
--- a/a.md
+++ b/a.md
@@ -0,0 +1 @@
+## git is awesome
複製程式碼

log

git log命令顯示提交歷史。

$ git log

commit 7e2514419ec0f75d1557d3d8165a7e7969f08349
Author: veedrin <veedrin@qq.com>
Date:   Sat Dec 29 11:56:53 2018 +0800
    c.md
commit 4d346773212b208380f71885979f93da65f07ea6
Author: veedrin <veedrin@qq.com>
Date:   Sat Dec 29 11:56:41 2018 +0800
    b.md
commit cde34665b49033d7b8aed3a334c3e2db2200b4dd
Author: veedrin <veedrin@qq.com>
Date:   Sat Dec 29 11:54:59 2018 +0800
    a.md
複製程式碼

如果要檢視每個commit具體的改動,新增-p引數,它是--patch的縮寫。

$ git log -p

commit 7e2514419ec0f75d1557d3d8165a7e7969f08349
Author: veedrin <veedrin@qq.com>
Date:   Sat Dec 29 11:56:53 2018 +0800
    c.md
diff --git a/c.md b/c.md
new file mode 100644
index 0000000..e69de29
commit 4d346773212b208380f71885979f93da65f07ea6
Author: veedrin <veedrin@qq.com>
Date:   Sat Dec 29 11:56:41 2018 +0800
    b.md
diff --git a/b.md b/b.md
new file mode 100644
index 0000000..e69de29
commit cde34665b49033d7b8aed3a334c3e2db2200b4dd
Author: veedrin <veedrin@qq.com>
Date:   Sat Dec 29 11:54:59 2018 +0800
    a.md
diff --git a/a.md b/a.md
new file mode 100644
index 0000000..e69de29
複製程式碼

你還可以控制顯示最近幾條。

$ git log -p -1

commit 7e2514419ec0f75d1557d3d8165a7e7969f08349
Author: veedrin <veedrin@qq.com>
Date:   Sat Dec 29 11:56:53 2018 +0800
    c.md
diff --git a/c.md b/c.md
new file mode 100644
index 0000000..e69de29
複製程式碼

-p有點過於冗餘,只是想檢視檔案修改的統計資訊的話,可以使用--stat引數。

$ git log --stat

commit 7e2514419ec0f75d1557d3d8165a7e7969f08349
Author: veedrin <veedrin@qq.com>
Date:   Sat Dec 29 11:56:53 2018 +0800
    c.md
 c.md | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
commit 4d346773212b208380f71885979f93da65f07ea6
Author: veedrin <veedrin@qq.com>
Date:   Sat Dec 29 11:56:41 2018 +0800
    b.md
 b.md | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
commit cde34665b49033d7b8aed3a334c3e2db2200b4dd
Author: veedrin <veedrin@qq.com>
Date:   Sat Dec 29 11:54:59 2018 +0800
    a.md
 a.md | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
複製程式碼

還覺得冗餘?只想看提交說明,有一個--oneline可以幫到你。

$ git log --oneline

4ad50f6 (HEAD -> master) 新增c.md檔案
4d34677 新增b.md檔案
cde3466 新增a.md檔案
複製程式碼

想在命令列工具看git提交歷史的樹形圖表,用--graph引數。

$ git log --graph

* commit 7e2514419ec0f75d1557d3d8165a7e7969f08349 (HEAD -> master)
| Author: veedrin <veedrin@qq.com>
| Date:   Sat Dec 29 11:56:53 2018 +0800
|     c.md
* commit 4d346773212b208380f71885979f93da65f07ea6
| Author: veedrin <veedrin@qq.com>
| Date:   Sat Dec 29 11:56:41 2018 +0800
|     b.md
* commit cde34665b49033d7b8aed3a334c3e2db2200b4dd
  Author: veedrin <veedrin@qq.com>
  Date:   Sat Dec 29 11:54:59 2018 +0800
      a.md
複製程式碼

我知道你們肯定又覺得冗餘,--graph--oneline食用更佳喲。

$ git log --graph --oneline

* 7e25144 (HEAD -> master) c.md
* 4d34677 b.md
* cde3466 a.md
複製程式碼

11) position

程式遇到bug的時候,我們需要快速定位。

定位有兩種,第一種是定位bug在哪個提交上,第二種是定位特定檔案的某一行是誰最近提交的。

bisect

有時候我們發現程式有bug,但是回退幾個版本都不解決問題。說明這個bug是一次很老的提交導致的,也不知道當時怎麼就沒察覺。

那怎麼辦呢?繼續一個一個版本的回退?

估計Linus Torvalds會鄙視你吧。

為了專注於工作,不分心來鄙視你,Linus Torvalds在git中內建了一套定位bug的命令。

大家都玩過猜數字遊戲吧。主持人悄悄寫下一個數,給大家一個數字區間,然後大家輪流開始切割,誰切到主持人寫的那個數就要自罰三杯了。

對,這就是二分法。git利用二分法定位bug的命令是git bisect

使用

假設目前的git專案歷史是這樣的。

C0 -- C1 -- C2 -- C3 -- C4 -- C5 -- C6 -- C7 -- C8 -- C9(HEAD -> master)
複製程式碼

這裡面有一次commit藏了一個bug,但幸運的是,你不知道是哪一次。

執行git bisect start命令,後跟你要定位的區間中最新的commit和最老的commit。

$ git bisect start HEAD C0

Bisecting: 4 revisions left to test after this (roughly 2 steps)
[ee27077fdfc6c0c9281c1b7f6957ea2b59a461dd] C4
複製程式碼

然後你就發現HEAD指標自動的指向了C4commit。如果範圍是奇數位,那取中間就行了,如果範圍是偶數位,則取中間更偏老的那個commit,就比如這裡的C4commit。

$ git bisect good

Bisecting: 2 revisions left to test after this (roughly 1 step)
[97cc0e879dc09796bd56cfd7c3a54deb41e447f6] C6
複製程式碼

HEAD指標指向C4commit後,你應該執行一下程式,如果沒問題,那說明有bug的提交在它之後。我們只需要告訴git當前commit以及更老的commit都是好的。

然後HEAD指標就自動指向C6commit。

繼續在C6commit執行程式,結果復現了bug。說明問題就出在C6commit和C4commit之間。

$ git bisect bad

Bisecting: 0 revisions left to test after this (roughly 0 steps)
[a7e09bd3eab7d1e824c0338233f358cafa682af0] C5
複製程式碼

C6commit標記為bad之後,HEAD指標自動指向C5commit。再次執行程式,依然能復現bug。話不多說,標記C5commit為bad

$ git bisect bad

a7e09bd3eab7d1e824c0338233f358cafa682af0 is the first bad commit
複製程式碼

因為C4commit和C5commit之間已經不需要二分了,git會告訴你,C5commit是你標記為bad的最早的commit。問題就應該出在C5commit上。

git bisect reset

Previous HEAD position was a7e09bd... C5
Switched to branch 'master'
複製程式碼

既然找到問題了,那就可以退出git bisect工具了。

另外,git bisect oldgit bisect good的效果相同,git bisect newgit bisect bad的效果相同,這是因為git考慮到,有時候開發者並不是想定位bug,只是想定位某個commit,這時候用good bad就會有點彆扭。

後悔

git bisect確實很強大,但如果我已經bisect若干次,結果不小心把一個goodcommit標記為bad,或者相反,難道我要reset重來麼?

git bisect還有一個log命令,我們只需要儲存bisect日誌到一個檔案,然後擦除檔案中標記錯誤的日誌,然後按新的日誌重新開始bisect就好了。

git bisect log > log.txt
複製程式碼

該命令的作用是將日誌儲存到log.txt檔案中。

看看log.txt檔案中的內容。

# bad: [4d5e75c7a9e6e65a168d6a2663e95b19da1e2b21] C9
# good: [c2fa7ca426cac9990ba27466520677bf1780af97] add a.md
git bisect start 'HEAD' 'c2fa7ca426cac9990ba27466520677bf1780af97'
# good: [ee27077fdfc6c0c9281c1b7f6957ea2b59a461dd] C4
git bisect good ee27077fdfc6c0c9281c1b7f6957ea2b59a461dd
# good: [97cc0e879dc09796bd56cfd7c3a54deb41e447f6] C6
git bisect good 97cc0e879dc09796bd56cfd7c3a54deb41e447f6
複製程式碼

將標記錯誤的內容去掉。

# bad: [4d5e75c7a9e6e65a168d6a2663e95b19da1e2b21] C9
# good: [c2fa7ca426cac9990ba27466520677bf1780af97] add a.md
git bisect start 'HEAD' 'c2fa7ca426cac9990ba27466520677bf1780af97'
# good: [ee27077fdfc6c0c9281c1b7f6957ea2b59a461dd] C4
git bisect good ee27077fdfc6c0c9281c1b7f6957ea2b59a461dd
複製程式碼

然後執行git bisect replay log.txt命令。

$ git bisect replay log.txt

Previous HEAD position was ad95ae3... C8
Switched to branch 'master'
Bisecting: 4 revisions left to test after this (roughly 2 steps)
[ee27077fdfc6c0c9281c1b7f6957ea2b59a461dd] C4
Bisecting: 2 revisions left to test after this (roughly 1 step)
[97cc0e879dc09796bd56cfd7c3a54deb41e447f6] C6
複製程式碼

git會根據log從頭開始重新bisect,錯誤的標記就被擦除了。

然後就是重新做人啦。

blame

一個充分協作的專案,每個檔案可能都被多個人改動過。當出現問題的時候,大家希望快速的知道,某個檔案的某一行是誰最後改動的,以便釐清責任。

git blame就是這樣一個命令。blame翻譯成中文是歸咎於,這個命令就是用來甩鍋的。

git blame只能作用於單個檔案。

$ git blame a.md

705d9622 (veedrin 2018-12-25 10:09:04 +0800 1) 第一行
74eff2ee (abby 2018-12-25 10:16:44 +0800 2) 第二行
a65b29bd (bob 2018-12-25 10:17:02 +0800 3) 第三行
ee27077f (veedrin 2018-12-25 10:19:05 +0800 4) 第四行
a7e09bd3 (veedrin 2018-12-25 10:19:19 +0800 5) 第五行
97cc0e87 (veedrin 2018-12-25 10:21:55 +0800 6) 第六行
67029a81 (veedrin 2018-12-25 10:22:15 +0800 7) 第七行
ad95ae3f (zhangsan 2018-12-25 10:23:20 +0800 8) 第八行
4d5e75c7 (lisi 2018-12-25 10:23:37 +0800 9) 第九行
複製程式碼

它會把每一行的修改者資訊都列出來。

第一部分是commit雜湊值,表示這一行的最近一次修改屬於該次提交。

第二部分是作者以及修改時間。

第三部分是行的內容。

如果檔案太長,我們可以擷取部分行。

$ git blame -L 1,5 a.md

705d9622 (veedrin 2018-12-25 10:09:04 +0800 1) 第一行
74eff2ee (abby 2018-12-25 10:16:44 +0800 2) 第二行
a65b29bd (bob 2018-12-25 10:17:02 +0800 3) 第三行
ee27077f (veedrin 2018-12-25 10:19:05 +0800 4) 第四行
a7e09bd3 (veedrin 2018-12-25 10:19:19 +0800 5) 第五行
複製程式碼

或者這樣寫。

$ git blame -L 1,+4 a.md

705d9622 (veedrin 2018-12-25 10:09:04 +0800 1) 第一行
74eff2ee (abby 2018-12-25 10:16:44 +0800 2) 第二行
a65b29bd (bob 2018-12-25 10:17:02 +0800 3) 第三行
ee27077f (veedrin 2018-12-25 10:19:05 +0800 4) 第四行
複製程式碼

但是結果不是你預期的那樣是吧。1,+4的確切意思是從1開始,顯示4行。

如果有人重名,可以顯示郵箱來區分。新增引數-e或者--show-email即可。

$ git blame -e a.md

705d9622 (veedrin@qq.com 2018-12-25 10:09:04 +0800 1) 第一行
74eff2ee (abby@qq.com 2018-12-25 10:16:44 +0800 2) 第二行
a65b29bd (bob@qq.com 2018-12-25 10:17:02 +0800 3) 第三行
ee27077f (veedrin@qq.com 2018-12-25 10:19:05 +0800 4) 第四行
a7e09bd3 (veedrin@qq.com 2018-12-25 10:19:19 +0800 5) 第五行
97cc0e87 (veedrin@qq.com 2018-12-25 10:21:55 +0800 6) 第六行
67029a81 (veedrin@qq.com 2018-12-25 10:22:15 +0800 7) 第七行
ad95ae3f (zhangsan@qq.com 2018-12-25 10:23:20 +0800 8) 第八行
4d5e75c7 (lisi@qq.com 2018-12-25 10:23:37 +0800 9) 第九行
複製程式碼

12) tag

git是一個版本管理工具,但在眾多版本中,肯定有一些版本是比較重要的,這時候我們希望給這些特定的版本打上標籤。比如釋出一年以後,程式的各項功能都趨於穩定,可以在聖誕節釋出v1.0版本。這個v1.0在git中就可以通過標籤實現。

而git標籤又分為兩種,輕量級標籤和含附註標籤。

輕量級標籤和分支的表現形式是一樣的,僅僅是一個指向commit的指標而已。只不過它不能切換,一旦貼上就無法再挪動了。

含附註標籤才是我們理解的那種標籤,它是一個獨立的git物件。包含標籤的名字,電子郵件地址和日期,以及標籤說明。

建立

建立輕量級標籤的命令很簡單,執行git tag <tag name>

$ git tag v0.3
複製程式碼

.git目錄中就多了一個指標檔案。

.git/refs/tags/v0.3
複製程式碼

建立含附註標籤要加一個引數-a,它是--annotated的縮寫。

$ git tag -a v1.0
複製程式碼

git commit一樣,如果不加-m引數,則會彈出預設或者自定義的編輯器,要求你寫標籤說明。

不寫呢?

fatal: no tag message?
複製程式碼

建立完含附註標籤後,.git目錄會多出兩個檔案。

.git/refs/tags/v0.3
複製程式碼
.git/objects/80/e79e91ce192e22a9fd860182da6649c4614ba1
複製程式碼

含附註標籤不僅會建立一個指標,還會建立一個tag物件。

我們瞭解過git有四種物件型別,tag型別是我們認識的最後一種。

我們看看該物件的型別。

$ git cat-file -t 80e79e9

tag
複製程式碼

再來看看該物件的內容。

$ git cat-file -p 80e79e9

object 359fd95229532cd352aec43aada8e6cea68d87a9
type commit
tag v1.0
tagger veedrin <veedrin@qq.com> 1545878480 +0800
版本 v1.0
複製程式碼

它關聯的是一個commit物件,包含標籤的名稱,打標籤的人,打標籤的時間以及標籤說明。

我可不可以給歷史commit打標籤呢?當然可以。

$ git tag -a v1.0 36ff0f5
複製程式碼

只需在後面加上commit的校驗和。

檢視

檢視當前git專案的標籤列表,執行git tag命令不帶任何引數即可。

$ git tag

v0.3
v1.0
複製程式碼

注意git標籤是按字母順序排列的,而不是按時間順序排列。

而且我並沒有找到分別檢視輕量級標籤和含附註標籤的方法。

檢視標籤詳情可以使用git show <tag name>

$ git show v0.3

commit 36ff0f58c8e6b6a441733e909dc95a6136a4f91b (tag: v0.3)
Author: veedrin <veedrin@qq.com>
Date:   Thu Dec 27 11:08:09 2018 +0800
    add a.md
diff --git a/a.md b/a.md
new file mode 100644
index 0000000..e69de29
複製程式碼
$ git show v1.0

tag v1.0
Tagger: veedrin <veedrin@qq.com>
Date:   Thu Dec 27 11:08:39 2018 +0800
版本 v1.0
commit 6dfdb65ce65b782a6cb57566bcc1141923059d2b (HEAD -> master, tag: v1.0)
Author: veedrin <veedrin@qq.com>
Date:   Thu Dec 27 11:08:33 2018 +0800
    add b.md
diff --git a/b.md b/b.md
new file mode 100644
index 0000000..e69de29
複製程式碼

刪除

雖然git標籤不能移動對吧,但我們可以刪除它呀。

$ git tag -d v0.3

Deleted tag 'v0.3' (was 36ff0f5)
複製程式碼

如果標籤已經推送到了遠端,也是可以刪除的。

$ git push origin -d v0.3

To github.com:veedrin/git.git
 - [deleted]         v0.3
複製程式碼

推送

預設情況下,git push推送到遠端倉庫並不會將標籤也推送上去。如果想將標籤推送到遠端與別人共享,我們得顯式的執行命令git push origin <tag name>

$ git push origin v1.0

Counting objects: 1, done.
Writing objects: 100% (1/1), 160 bytes | 160.00 KiB/s, done.
Total 1 (delta 0), reused 0 (delta 0)
To github.com:veedrin/git.git
 * [new tag]         v1.0 -> v1.0
複製程式碼

這裡並不區分輕量級標籤和含附註標籤。

一次性將本地標籤推送到遠端倉庫也是可以的。

$ git push origin --tags
複製程式碼

本文是『horseshoe·Git專題』系列文章之一,後續會有更多專題推出

GitHub地址(持續更新):github.com/veedrin/hor…

部落格地址(文章排版真的很漂亮):veedrin.com

如果覺得對你有幫助,歡迎來GitHub點Star或者來我的部落格親口告訴我

Git專題一覽

? add

? commit

? branch

? checkout

? merge

? rebase

? reset

? revert

? stash

? view

? position

? tag

? remote

相關文章