乐趣区

2018(农历年)封山之作,和我一起嚼烂Git(两万字长文)

本文是『horseshoe·Git 专题』系列文章之一,后续会有更多专题推出 GitHub 地址(持续更新):https://github.com/veedrin/horseshoe 博客地址(文章排版真的很漂亮):https://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 -a
它和 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,一开始这个目录下面只有两个空目录:info 和 pack。
一旦我们执行了 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 - a 命令一样,未跟踪的文件是无法提交上去的。
重写提交
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)
merge 与 rebase 后跟的分支名是不一样的。合并是合并进来,变基是变基过去,你们感受一下。
$ 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 add 和 git 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-pick 和 git 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.md,revert 操作就会执行删除命令。在工作目录看起来就像添加文件操作被撤销了一样,其实是被对冲了。
它不会改变 commit 历史,只会增加一个新的对冲 commit。这是它最大的优点。
冲突
反向操作也会有冲突?你逗我的吧。
如果你操作的是最新的 commit,那当然不会有冲突了。
那要操作的是以前的 commit 呢?
C0 — C1 — C2(HEAD -> master)
比如 a.md 在 C0 内容为空,C1 修改文件内容为 apple,C2 修改文件内容为 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
WIP 是 work 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 永远将两个主体分别命名为 a 和 b。
也可以只查看某个文件的 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 old 和 git bisect good 的效果相同,git bisect new 和 git 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
13) remote
git 是分布式版本管理工具,它没有中央仓库。但多人协作时,我们依然需要一个集散地,让协作成员之间统一往集散地推送和拉取更新。否则,点对点的沟通,效率会很低。
所以就引出了 git 中远端仓库的概念。
概念
我们之前所有的操作都是在本地仓库完成的,和本地仓库对应的是远端仓库。那么本地有若干分支,远端仓库是不是也有对应的若干分支呢?
当然。
我们探讨一个问题,在离线状态下,git 是不是无从知道远端仓库的任何状态?
我让网络下线,查询从 github 克隆下来的本地仓库的状态,结果它告诉我本地仓库的 master 分支是 up to date with ‘origin/master’。
$ git status

On branch master
Your branch is up to date with ‘origin/master’.

nothing to commit, working tree clean
实际上,git 的分支有三种:

本地分支,我们可以通过 <branch> 写法访问它。
远端分支,我们可以通过 <remote branch> 写法访问它。
远端分支引用,我们可以通过 <remote/branch> 写法访问它。实际上它也是本地分支,只不过我们无法操作它,只有 git 的网络操作才可以更新它。离线状态下,git 给的状态就是本地分支和远端分支引用的比较结果。

git 官方把我所说的远端分支引用称为远端分支。知道谁是谁就行了,名字不重要???? 我是马蹄疾

我们看一下本地的远端分支引用。
.git/
.git/refs/
.git/refs/remotes/
.git/refs/remotes/origin/
.git/refs/remotes/origin/HEAD
.git/refs/remotes/origin/master
默认的远端仓库名就叫 origin。它也有 master 分支指针,也有 HEAD 指针。
拉取
如果远端仓库有新的提交或者新的分支,我们需要运行 git fetch 命令来拉取更新。
$ git fetch

remote: Enumerating objects: 5, done.
remote: Counting objects: 100% (5/5), done.
remote: Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
Unpacking objects: 100% (3/3), done.
From github.com:veedrin/git
3893459..0f80eeb master -> origin/master
这个命令是 git fetch origin 的缩写。因为 origin 是远端仓库的默认名称,所以可以省略。如果有手动添加的远端仓库,那就必须指定远端仓库的名称了。
这个命令做了什么呢?
它会把新的提交和新的分支拉取到本地,然后更新本地的远端分支引用到最新的提交。
git fetch 仅仅是将远端的更新拉取下来,同步本地的远端分支引用,不会对本地分支有任何影响。我们需要手动执行合并操作才能更新本地分支。
$ git merge origin/master

On branch master
Your branch is up to date with ‘origin/master’.
nothing to commit, working tree clean
当然,有一个更简单的操作。
$ git pull

remote: Enumerating objects: 5, done.
remote: Counting objects: 100% (5/5), done.
Unpacking objects: 100% (3/3), done.
remote: Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
From github.com:veedrin/git
4fbd1d4..d9785d7 master -> origin/master
Updating 4fbd1d4..d9785d7
Fast-forward
README.md | 2 ++
1 file changed, 2 insertions(+)
git pull 就是 git fetch 和 git merge 的一键操作。
推送
推送到远端的命令是 git push <remote-name> <remote-branch-name>。
$ git push origin master

Counting objects: 3, done.
Writing objects: 100% (3/3), 261 bytes | 261.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0)
To github.com:veedrin/git.git
3eaa1ae..2bd3c9d master -> master
如果当前分支对远端分支设置了追踪的话,也可以省略分支名。
$ git push

Counting objects: 3, done.
Writing objects: 100% (3/3), 261 bytes | 261.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0)
To github.com:veedrin/git.git
3eaa1ae..2bd3c9d master -> master
有时候本地分支和远端分支同时有新的提交,直接 push 是不行的。
$ git push

To github.com:veedrin/git.git
! [rejected] master -> master (fetch first)
error: failed to push some refs to ‘git@github.com:veedrin/git.git’
hint: Updates were rejected because the remote contains work that you do
hint: not have locally. This is usually caused by another repository pushing
hint: to the same ref. You may want to first integrate the remote changes
hint: (e.g., ‘git pull …’) before pushing again.
hint: See the ‘Note about fast-forwards’ in ‘git push –help’ for details.
有两种方式解决。
第一是先把远端的更新拉下来,有冲突则解决冲突,没冲突则再推送。
第二是强推。有时候我们就是想覆盖远端对吧,也不是不行,但是必须十分谨慎。而且不要在公共分支上强制推送。
$ git push -f

Counting objects: 24, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (8/8), done.
Writing objects: 100% (24/24), 3.72 KiB | 1.24 MiB/s, done.
Total 24 (delta 0), reused 3 (delta 0)
To github.com:veedrin/git.git
+ 54d741b…2db10e0 master -> master (forced update)
实际开发时我们会建很多特性分支,推送到远端,通过测试后再合入主分支。使用 git push <remote-name> <remote-branch-name> 每次都要指定远端分支名,如果会有多次推送,我们可以在推送时设置本地分支追踪远端分支,这样下次就可以直接推送了。
也可以简写成 git push -u <remote-name> <remote-branch-name>。
$ git push –set-upstream origin dev

Counting objects: 3, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (2/2), done.
Writing objects: 100% (3/3), 255 bytes | 255.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0)
remote:
remote: Create a pull request for ‘dev’ on GitHub by visiting:
remote: https://github.com/veedrin/git/pull/new/dev
remote:
To github.com:veedrin/git.git
* [new branch] dev -> dev
Branch ‘dev’ set up to track remote branch ‘dev’ from ‘origin’.
然后我们在.git/config 文件中能看到多了一条配置。
[branch “dev”]
remote = origin
merge = refs/heads/dev
查看
查看远端仓库的命令是 git remote。
$ git remote

origin
加 - v 参数可以查看更为详细的信息,- v 是 –verbose 的缩写。
$ git remote -v

origin git@github.com:veedrin/git.git (fetch)
origin git@github.com:veedrin/git.git (push)
查看某个远端仓库的信息,可以使用命令 git remote show <remote-name>。
$ git remote show origin

* remote origin
Fetch URL: git@github.com:veedrin/git-1.git
Push URL: git@github.com:veedrin/git-1.git
HEAD branch: master
Remote branches:
dev tracked
master tracked
Local branches configured for ‘git pull’:
dev merges with remote dev
master merges with remote master
Local refs configured for ‘git push’:
master pushes to master (up to date)
添加
添加新的远端仓库,使用 git remote add <shortname> <url> 命令。
$ git remote add horseshoe https://github.com/veedrin/horseshoe
然后本地就多了一个远端仓库。
$ git remote

horseshoe
origin
除了添加远端仓库,我们还可以添加本地分支对远端分支的追踪。
$ git checkout -b dev origin/dev

Branch ‘dev’ set up to track remote branch ‘dev’ from ‘origin’.
Switched to a new branch ‘dev’
创建 dev 分支的同时,也设置了对远端分支 dev 的追踪,这样下次推送的时候就不需要指定了。
当然,远端分支引用必须得存在才行。
$ git checkout -b dev origin/dev

fatal: ‘origin/dev’ is not a commit and a branch ‘dev’ cannot be created from it
git 也提供了快捷方式。
$ git checkout –track origin/dev

Branch ‘dev’ set up to track remote branch ‘dev’ from ‘origin’.
Switched to a new branch ‘dev’
重命名
有时候你想修改远端仓库的简写名。比如你将女朋友的名字命名为远端仓库的简写名,然后你们分手了。这真是一个令人悲伤 (欣喜) 的故事。
$ git remote rename nvpengyou gaoyuanyuan
查看远端仓库列表。
$ git remote

gaoyuanyuan
origin
删除
一般来说,一个 git 项目有一个远端仓库就行了,其余的大多是临时性的。所以总有一天要删除它。
$ git remote rm horseshoe
查看远端仓库列表。
$ git remote

origin
本文是『horseshoe·Git 专题』系列文章之一,后续会有更多专题推出 GitHub 地址(持续更新):https://github.com/veedrin/horseshoe 博客地址(文章排版真的很漂亮):https://veedrin.com 如果觉得对你有帮助,欢迎来 GitHub 点 Star 或者来我的博客亲口告诉我
Git 专题一览
???? add
???? commit
???? branch
???? checkout
???? merge
???? rebase
???? reset
???? revert
???? stash
???? view
???? position
???? tag
???? remote

退出移动版