假設我們有兩個分支,a 和 b,它們的提交都有一個相同的父提交(master 指向的那次提交)。如圖所示:
現在我們在分支 b 上,然後 rabase 到分支 a 上。如圖所示:
平時開發中經常遇到這種情況,假設分支 a 和 b 是兩個獨立的 feature 分支,但是不小心被我們錯誤的 rebase 了。現在相當於兩個 feature 分支中原本獨立的業務被揉起來了,當然是我們不想看到的結果,那麼如何撤銷呢?
一種方案是利用 reflog 命令。
利用 reflog 撤銷變基
我們先不考慮原理,直接上解決方案,首先輸入 git reflog
,你會看到如下圖所示的日誌:
最後的輸出其實是最早的操作,我們逐條分析下:
- HEAD@{8}: 這裡我們建立了初始的提交
- HEAD@{7}:檢出了分支 a
- HEAD@{6}:在分支 a 上做了一次提交,注意 master 分支沒有變動
- HEAD@{5}:從分支 a 回到分支 master,相當於向後退了一次
- HEAD@{4}:檢出了分支 b
- HEAD@{3}:在分支 b 上做了一次提交,注意 master 分支沒有變動
- HEAD@{2}:這一步開始變基到分支 a,首先切換到分支 a 上
- HEAD@{1}:把分支 b 對應的那次提交變基到分支 a 上
- HEAD@{0}:變基結束,因為是在 b 上發起的變基,所以最後還切回分支 b
如果我們想撤銷此次 rebase,只要輸入以下命令就可以了:
git reset --hard HEAD@{3}
複製程式碼
此時再看,已經“恢復”到 rebase 前的狀態了。的是不是感覺很神奇呢,先彆著急,後面會介紹這麼做的原理。
git 工作原理簡介
為了搞懂 git 是如何工作的,以及這些命令背後的原理,我想有必要對 git 的模型有基礎的瞭解。
首先,每一個 git 目錄都有一個名為 .git
的隱藏目錄,關於 git 的一切都儲存於這個目錄裡面(全域性配置除外)。這個目錄裡面有一些子目錄和檔案,檔案其實不重要,都是一些配置資訊,後面會介紹其中的 HEAD 檔案。子目錄有以下幾個:
- info:這個目錄不重要,裡面有一個 exclude 檔案和
.gitignore
檔案的作用相似,區別是這個檔案不會被納入版本控制,所以可以做一些個人配置。 - hooks:這個目錄很容易理解, 主要用來放一些 git 鉤子,在指定任務觸發前後做一些自定義的配置,這是另外一個單獨的話題,本文不會具體介紹。
- objects:用於存放所有 git 中的物件,下面單獨介紹。
- logs:用於記錄各個分支的移動情況,下面單獨介紹。
- refs:用於記錄所有的引用,下面單獨介紹。
本文主要會介紹後面三個資料夾的作用。
git 物件
git 是物件導向的! git 是物件導向的! git 是物件導向的!
沒錯,git 是物件導向的,而且很多東西都是物件。我舉個簡單的例子,來幫助大家理解這個概念。假設我們在一個空倉庫裡,編輯了 2 個檔案,然後提交。此時都會有那些物件呢?
首先會有兩個資料物件,每個檔案都對應一個資料物件。當檔案被修改時,即使是新增了一個字母,也會生成一個新的資料物件。
其次,會有一個樹物件用來維護一系列的資料物件,叫樹物件的原因是它持有的不僅可以是資料物件,還可以是另一個樹物件。比如某次提交了兩個檔案和一個資料夾,那麼樹物件裡面就有三個物件,兩個是資料物件,資料夾則用另一個樹物件表示。這樣遞迴下去就可以表示任意層次的檔案了。
最後則是提交物件,每個提交物件都有一個樹物件,用來表示某一次提交所涉及的檔案。除此以外,每一個提交還有自己的父提交,指向上一次提交的物件。當然,提交物件還會包含提交時間、提交者姓名、郵箱等輔助資訊,就不多說了。
假設我們只有一個分支,以上知識點就足夠解釋 git 的提交歷史是如何計算的了。它並不儲存完整的提交歷史,而是通過父提交的物件不斷向前查詢,得出完整的歷史。
注意開頭那張圖片,分支 b 指向的提交是 9cbb015
,不妨來看下它是何方神聖:
git cat-file -t 9cbb015
git cat-file -p 9cbb015
複製程式碼
這裡我們使用 cat-file
命令,其中 -t
引數列印物件的型別,-p
引數會智慧識別型別,並列印其中的內容。輸出結果如圖所示:
可見 9cbb015
是一個提交物件,裡面包含了樹物件、父提交物件和各種配置資訊。我們可以再列印樹物件看看:
這表示本次提交只修改了 begin 這個檔案,並且輸出了 begin 這個檔案對於的資料物件。
git 引用
既然 git 是物件導向的,那麼有沒有指正呢?還真是有的,分支和標籤都是指向提交物件的指標。這一點可以驗證:
cat .git/refs/heads/a
複製程式碼
所有的本地分支都儲存在 git/refs/heads
目錄下,每一個分支對應一個檔案,檔案的內容如圖所示:
可見,4a3a88d
剛好是本文第一張圖中分支 a 所指向的提交。
我們已經搞明白了 git 分支的祕密,現在有了所有分支的記錄,又有了每次提交的父提交物件,就能夠得出像 SourceTree 或者文章開頭第一張圖那樣的提交狀態了。
至於標籤,它其實也是一種引用,可以理解為不能移動的分支。只能永遠指向某個固定的提交。
最後一個比較特殊的引用是 HEAD,它可以理解為指標的指標,為了證明這一點,我們看看 .git/HEAD
檔案:
它的內容記錄了當前指向哪個分支,refs/heads/b
其實是一個檔案,這個檔案的內容是分支 b 指向的那個提交物件。理解這一點非常重要,否則你會無法理解 checkout
和 reset
的區別。
這兩個命令都會改變 HEAD 的指向,區別是 checkout
不改變 HEAD 指向的分支的指向,而 reset
會。舉個例子, 在分支 b 上執行以下兩個命令都會讓 HEAD 指向 4a3a88d
這次提交(分支 a 指向的提交):
git checkout a
git reset --hard a
複製程式碼
但 checkout
僅改變 HEAD 的指向,不會改變分支 b 的指向。而 reset
不僅會改變 HEAD 的指向,還因為 HEAD 指向分支 b
,就把 b 也指向 4a3a88d
這次提交。
git 日誌
在 .git/logs
目錄中,有一個資料夾和一個 HEAD 檔案,每當 HEAD 引用改變了指向的位置,就會在 .git/logs/HEAD
中新增了一個記錄。而 .git/logs/refs/heads
這個目錄中則有多個檔案,每個檔案對應一個分支,記錄了這個分支 的指向位置發生改變的情況。
當我們執行 git reflog
的時候,其實就是讀取了 .git/logs/HEAD
這個檔案。
撤銷 rebase 的原理
首先我們要排除一個誤區,那就是 git 會維護每次提交的提交物件、樹物件和資料物件,但並不會維護每次提交時,各個分支的指向。在介紹分支的那一節中我們已經看到,分支僅僅是一個保留了提交物件的檔案而已,並不記錄歷史資訊。即使在上一節中,我們知道分支的變化資訊會被記錄下來,但也不會和某個提交物件繫結。
也就是說,git 中並不存在某次提交時的分支快照
那麼我們是如何通過 reset 來撤銷 rebase 的呢,這裡還要澄清另一個事實。前文曾經說過,某個時刻下你通過 SourceTree 或者 git log
看到的分支狀態,其實是由所有分支的列表、每個分支所指向的提交,和每個提交的父提交共同繪製出來的。
首先 git/refs/heads
下的檔案告訴我們有多少分支,每個檔案的內容告訴我們這個分支指向那個提交,有了這個提交不斷向前追溯就繪製出了這個分支的提交歷史。所有分子的提交歷史也就組成了我們看到的狀態。
但我們要明確:不是所有提交物件都能看到的,舉個例子如果我們把某個分支向前移一次提交,那個分支的提交線就會少一個節點,如果沒有別的提交線包含這個節點,這個節點就看不到了。
所以在 rebase 完成後,我們以為看到了下面這樣的提交線:
df0f2c5(master) --- 4a3a88d(a) --- 9cbb015(b)
複製程式碼
實際上是這樣的:
df0f2c5(master) --- 4a3a88d(a) --- 9d0618e(b)
|
9cbb015
複製程式碼
master 分支上依然有分叉,原來 9cbb015
這次提交依然存在,只不過沒有分支的提交線包含它,所以無法看到而已。但是通過 reflog
,我們可以找回 HEAD 頭的每一次移動,所以能看到這次提交。
當我們執行這個命令時:
git reset --hard HEAD@{3}
複製程式碼
再看一次 reflog
的輸出:
HEAD@{3}
其實是它左側 9cbb015
這次提交的縮寫,所以上述命令等價於:
git reset --hard 9cbb015
複製程式碼
前文說過,reset
不僅會移動 HEAD,還會移動 HEAD 所指向的分支,所以這個命令的執行結果就是讓 HEAD 和分支 b 同時指向 9cbb015
這個提交,看起來像是撤銷了 rebase。
但別忘了,分支 a 的上面還是有一次提交的,9d0618e 這次提交僅僅是沒有分支指向它,所以不顯示而已。但它真實的存在著,嚴格意義上來說,我們並沒有真正的撤銷此次 rebase。