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

20,021 阅读41分钟

本文是『horseshoe·Git专题』系列文章之一,后续会有更多专题推出

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

博客地址(文章排版真的很漂亮):matiji.cn

如果觉得对你有帮助,欢迎来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…

博客地址(文章排版真的很漂亮):matiji.cn

如果觉得对你有帮助,欢迎来GitHub点Star或者来我的博客亲口告诉我

Git专题一览

🎖 add

🎖 commit

🎖 branch

🎖 checkout

🎖 merge

🎖 rebase

🎖 reset

🎖 revert

🎖 stash

🎖 view

🎖 position

🎖 tag

🎖 remote