假设咱们有两个分支,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。