假設咱們有兩個分支,a 和 b,它們的提交都有一個相同的父提交(master 指向的那次提交)。如圖所示:git
如今咱們在分支 b 上,而後 rabase 到分支 a 上。如圖所示:shell
平時開發中常常遇到這種狀況,假設分支 a 和 b 是兩個獨立的 feature 分支,可是不當心被咱們錯誤的 rebase 了。如今至關於兩個 feature 分支中本來獨立的業務被揉起來了,固然是咱們不想看到的結果,那麼如何撤銷呢?bash
一種方案是利用 reflog 命令。spa
咱們先不考慮原理,直接上解決方案,首先輸入 git reflog
,你會看到以下圖所示的日誌:3d
最後的輸出實際上是最先的操做,咱們逐條分析下:版本控制
若是咱們想撤銷這次 rebase,只要輸入如下命令就能夠了:指針
git reset --hard HEAD@{3}
複製代碼
此時再看,已經「恢復」到 rebase 前的狀態了。的是否是感受很神奇呢,先彆着急,後面會介紹這麼作的原理。日誌
爲了搞懂 git 是如何工做的,以及這些命令背後的原理,我想有必要對 git 的模型有基礎的瞭解。code
首先,每個 git 目錄都有一個名爲 .git
的隱藏目錄,關於 git 的一切都存儲於這個目錄裏面(全局配置除外)。這個目錄裏面有一些子目錄和文件,文件其實不重要,都是一些配置信息,後面會介紹其中的 HEAD 文件。子目錄有如下幾個:cdn
.gitignore
文件的做用類似,區別是這個文件不會被歸入版本控制,因此能夠作一些我的配置。本文主要會介紹後面三個文件夾的做用。
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 是面向對象的,那麼有沒有指正呢?還真是有的,分支和標籤都是指向提交對象的指針。這一點能夠驗證:
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/logs
目錄中,有一個文件夾和一個 HEAD 文件,每當 HEAD 引用改變了指向的位置,就會在 .git/logs/HEAD
中添加了一個記錄。而 .git/logs/refs/heads
這個目錄中則有多個文件,每一個文件對應一個分支,記錄了這個分支 的指向位置發生改變的狀況。
當咱們執行 git reflog
的時候,其實就是讀取了 .git/logs/HEAD
這個文件。
首先咱們要排除一個誤區,那就是 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。