【Git使用技巧】Git 的原理和技巧

566 阅读35分钟

这篇文章描述了 Git 的基本原理,以及对常用技巧进行详解,按照最简单易懂的方式编写。如果你下定决心要弄懂 Git 的实现原理,拨开平常使用过程中的迷雾,它将是你的不二之选。 另外,这篇文章有点长,因为它期望将所有内容都整合进来,你可以每次只看一小节。 它会不定期更新维护...

文章基本结构

基础回顾

我们已经很熟练地来使用 Git 完成完成日常开发工作了,但是我们还需要回顾一下基本概念。

Git 与其他版本控制系统的区别

Git 与其他版本控制系统最大的差别在于 Git 对待数据的方法。

其他系统保存的信息是初始文件,以及随着时间积累各文件之间产生的差异。

保存初始文件与差异

Git 直接记录快照,而非差异比较。每次提交时,Git 会将文件的快照保存起来并制作这个快照的索引。通过该索引,我们可以找到这个快照。

保存项目随时间改变的快照

怎么理解快照?

A snapshot of a file system is an image of a file system at a particular point in time. A snapshot of a file system may be used to restore the file system to its state at the time of creation of the snapshot in。

文件系统的快照是文件系统在特定时间点的映像。文件系统的快照可以用于将文件系统恢复到创建快照时的状态。

你一定还是不理解"快照"的意思。没关系,这里仅需要对"快照"有一个模糊的概念。我们后面会亲眼看一看"快照"是什么,怎么被制作和保存的。

.git 目录概览

在开始之前,我们还需要回顾一下 .git 目录中的内容。

当在一个新目录或者已有目录执行 git init 时,Git 会创建一个 .git 目录。这个目录包含了几乎所有 Git 存储和操作的对象。 如若想备份或复制一个版本库,只需把这个目录拷贝至另一处即可。

该目录默认的内容结构如下:

文件或目录 说明
HEAD 指示目前被检出的分支
config 包含项目特有的配置
description 仅供 GitWeb 程序使用,无需关心
hooks/ 目录包含客户端或服务端的钩子脚本(hook scripts)
info/ 包含一个全局性排除文件
objects/ 存储所有数据内容
refs/ 存储指向数据(分支)的提交对象的指针
index 保存暂存区信息

上述目录结构中 index 文件暂时还没有创建,但它很重要,所以笔者将它列在这里。

该目录下可能还会包含其它文件,不过对于一个全新的 git init 版本库,这是我们看到的默认结构。

文件状态与三个区域

项目中的文件有两种类型,未追踪文件和已追踪文件。

未追踪是指 Git 不会去关心它的变化。例如这个例子中 Git 并不关心 test.txt 文件:

$ git init test #初始化一个 git 仓库,目录名称为 test
Initialized empty Git repository in /Users/cj/Documents/test/.git/
$ cd test
$ echo 'an untracked file' > test.txt #创建 test.txt 文件
$ git status
On branch master

No commits yet

Untracked files:
  (use "git add <file>..." to include in what will be committed)

	test.txt

nothing added to commit but untracked files present (use "git add" to track)

执行 git add test.txt 就可以将它添加到暂存区,并让 Git 追踪它。

已追踪的文件有三种状态,我们的文件可能处于其中之一:已修改、已暂存、已提交。

文件状态 描述
已修改(modified) 已经修改了文件,但还没有暂存
已暂存(staged) 表示对一个已修改文件的当前版本做了标记
已提交(commited) 数据已经安全的保存在本地数据库中

由此也引出了 Git 项目三个区域的概念:

三个区域
![](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2020/6/8/17292836134264ce~tplv-t2oaga2asx-image.image)

区域 描述
工作目录 也就是我们肉眼能够看到的文件。
它是从项目的某个版本独立提取出来的内容,放在磁盘上供我们使用或修改。
使用 git add 命令就会将工作目录中的文件快照放到暂存区。
暂存区(也叫 Index 区) 暂存区就是 .git 中的 index 文件。保存了下次将提交的文件列表信息。
使用 git commit 相关命令之后,就会把 stage 中的内容保存到 Git 仓库。
Git 仓库(也叫 commit history) Git 仓库目录是 Git 用来保存项目的元数据和对象数据库的地方。
任何提交只要进入 commit history,基本可以认为永远不会丢失了。

基本的 Git 工作流程如下:

  1. 在工作目录中修改文件。
  2. 暂存文件,将文件的快照放入暂存区域。
  3. 提交更新,找到暂存区域的文件,将快照永久性存储到 Git 仓库目录。

查看工作目录与暂存区的状态,使用 git status 命令。

查看 Git 仓库的状态,使用 git log 命令。

如果你对上述内容完全理解,那么我们就进入下面的内容。

三个对象

简介

blob 对象

用于保存我们在文件中编辑的数据内容。

tree 对象

tree 在我们这里的实际意思是 "文件的集合",而不是指特定的数据结构。

首先,它可以保存 blob 对象 ,以及 blob 对象对应的文件名。(即 tree 可以保存文件)

再者,它还可以保存另一棵 tree,以及另一棵 tree 对应的目录名。(即 tree 可以保存目录)

所以,项目中的所有内容都可以用一些 blob 对象和一些 tree 对象来描述。

如果用这些 blobtree ,再生成一棵 tree 。那么这棵生成的 tree ,就是这些文件的 "集合",就是这些文件的快照,它记录了当前时刻项目的状态,保存了所有的内容!

一系列的 tree ,就是一系列的快照。

commit 对象

保存快照的 SHA-1 值,以及提交者、提交时间等相关信息。

commit 对象还可以有任意多个父提交对象。如果只有一个父提交对象,那么它是一个普通的提交对象;如果有多个父提交对象,那么它是由合并得到的提交对象;初始提交对象没有父提交对象。

我们可以通过 commit 对象,找到任意"一张"快照,来查看某一时刻项目的状态。

详细介绍

下面来亲眼看一看这三种对象。

blob 对象

Git 的核心部分是一个键值对数据库(key-value data store)。向数据库中插入一个对象,它会返回一个 key。使用这个 key,我们可以在任意时刻再次检索到这个对象。

这里不要着急,有时间的话跟着笔者一起敲下面的命令。

向 Git 数据库中存入文本数据

首先,我们新创建一个 git 仓库。

#创建一个 git 仓库,目录名为 test
$ git init test 
Initialized empty Git repository in /Users/cj/Documents/test/.git/
$ cd test 
#查找 .git/objects 下的所有目录和文件
$ find .git/objects 
.git/objects
.git/objects/pack
.git/objects/info

可以看到,我们新创建一个 Git 仓库时,Git 对 .git/objects 目录进行了初始化,创建了 packinfo 子目录,但均为空。

接着,我们通过 hash-object 指令直接往 Git 数据库存入一些文本:

$ echo 'test content' | git hash-object -w --stdin
d670460b4b4aece5915caf5c68d12f560a9fe3e4

上述指令你可能会困惑,我在这里列出解释:

指令、参数、其它 含义
| 表示将上条指令的输出作为下一条指令的参数
hash-object 将任意数据保存到 .git 仓库,并返回相应的 key
-w 指示 hash-object 命令存储数据对象
若不指定此选项,则该命令仅返回对应的 key
--stdin 指示 git hash-object 命令从标准输入读取内容
若不指定此选项,则须在命令尾部给出一个文件路径,Git 将存储该文件的内容
d670460b4b4aece5915caf5c68d12f560a9fe3e4 SHA-1 哈希值,长度为 40 个字符的校验和

现在,我们已经往 Git 数据库中写入了文本。让我们再来查看一下 .git/objects

$ find .git/objects
.git/objects
.git/objects/d6 #多了一个 d6 目录
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4 # 多了一个文件
.git/objects/pack
.git/objects/info

可以看到,.git/objects 文件夹下面多了一个子目录,以及子目录下多了一个文件。

这就是 Git 存储内容的方式,一个文件对应一条内容,以该内容加上特定头部信息一起的 SHA-1 校验和为文件命名。 校验和的前两个字符用于命名子目录,余下的 38 个字符则用作文件名。

SHA-1 校验和

Git 将内容 + 头部 信息拼接起来,计算出这条新内容的 SHA-1 校验和,这就是我们平时看到的 SHA-1 校验和。

为什么要加一个头部信息呢?

头部信息主要是为了指明这条 SHA-1 校验和指代的对象的类型,比如 blob、tree 或者 commit。

我们将上面做的事情整理一下:

  1. 我们向 Git 中存储了一个对象,它是文本 test content
  2. Git 将对象以文件的形式存储到 .git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4
  3. Git 返回给我们了一个 key: d670460b4b4aece5915caf5c68d12f560a9fe3e4

请确保理解了我们之前做的事情,再接着进行。

通过 key 从 Git 中读取数据

我们可以通过 key 来查看对象的类型,以及取出对象,使用 cat-file 就可以查看 Git 对象:

$ git cat-file -t d670460b4b4aece5915caf5c68d12f560a9fe3e4
blob 

$ git cat-file -p d670460b4b4aece5915caf5c68d12f560a9fe3e4
test content
参数 作用
-t 查看对象的类型
-p 参数表示自动判断对象的类型,并为我们显示格式友好的内容

至此,我们已经可以向 Git 中存入文本,并且通过 key 来读取它!Git 就是这样的一个数据库。

向 Git 数据库中存入文件数据

我们还可以对文件进行同样的操作。

首先,创建一个新文件,并将其存入数据库:

$ echo 'version 1' > test.txt # 创建 test.txt 文件,里面的文本内容是 'version 1'
$ git hash-object -w test.txt # 将文件存入 Git 数据库
83baae61804e65cc73a7201a7252750c76066a30 # Git 返回 key

然后,我们向文件里写入新内容,并再次将其存入数据库:

$ echo 'version 2' > test.txt # 将 test.txt 中文本的内容覆盖为 'version 2'
$ git hash-object -w test.txt # 再次将文件存入 Git 数据库
1f7a7a472abf3dd9643fd615f6da379c4acb3e3a # Git 返回 key

先想一想,Git 数据库中现在有几条数据?

三条!我们查看 .git/objects

$ find .git/objects -type f # -type f 表示指查找文件(不找目录)
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4 #这是我们第一次存的 'test content'
.git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a #这是我们第三次存的 'version 2'
.git/objects/83/baae61804e65cc73a7201a7252750c76066a30 #这是我们第二次存的 'version 1'

它保存了三条数据,它们是以文件的形式保存的。

使用 Git 数据库中的内容来恢复工作区的内容

现在,工作区中 test.txt 文件的内容是 version 2

$ cat test.txt 
version 2

我们可以把内容恢复到 version 1 或者 version 2

$ git cat-file -p 83baae61804e65cc73a7201a7252750c76066a30 > test.txt
$ cat test.txt
version 1

$ git cat-file -p 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a > test.txt
$ cat test.txt
version 2

在这个(简单的版本控制)系统中,文件名并没有被保存——我们仅保存了文件的内容。像文件内容这种类型的对象,我们称之为数据对象(blob object)。

只要给定对象的 SHA-1 值,就可以利用 cat-file -t 命令,让 Git 告诉我们它内部存储的对象类型:

$ git cat-file -t 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a > test.txt
blob

树对象

理解了 blob 对象后,我们再来看一下 tree 对象。

在上面 blob 对象的操作中,虽然 blob 已经能够保存文件的内容了,但它并没有保存下来文件名。这显然还不能够完整描述我们项目当前的状态。并且,我们也不可能记住每一个 blob 的 SHA-1 值。

树对象(tree object)能解决文件名保存的问题;并且能够将多个文件/目录组织到一起,找到 tree 就能找到 tree 中的文件/目录。

Git 以一种类似于 UNIX 文件系统的方式存储内容,但作了些许简化。

所有内容均以树对象和数据对象的形式存储,其中树对象对应了 UNIX 中的目录项,数据对象则大致上对应了文件内容。

一个树对象包含了一条或多条树对象记录,每条记录含有一个指向数据对象或者子树对象的 SHA-1 指针,以及相应的模式、类型、文件名信息。

Git 能够根据某一时刻暂存区所表示的状态创建并记录一个对应的树对象(快照)。

创建一个树对象

我们先整理一下我们之前向 Git 中存入的 blob 对象:

key 类型 value 说明
d670460b4b4aece5915caf5c68d12f560a9fe3e4 blob test content 我们第一次存入的文本
83baae61804e65cc73a7201a7252750c76066a30 blob version 1 test.txt 的第一个版本
1f7a7a472abf3dd9643fd615f6da379c4acb3e3a blob version 2 test.txt 的第二个版本

我现在想创建一个快照。为此,我需要先暂存一些文件。

首先,使用 update-indextest.txt 文件的第一个版本 人为地加入到暂存区:

$ git update-index --add --cacheinfo 100644 83baae61804e65cc73a7201a7252750c76066a30 test.txt
$ git ls-files --stage
100644 83baae61804e65cc73a7201a7252750c76066a30 0	test.txt
item 含义
update-index 可以为一个单独文件创建一个暂存区
--add 因为此前该文件并不在暂存区中,所以必须指定该选项
--cacheinfo 表示要注册地点文件位于数据库中,而不是当前目录下
100644 文件的模式,表示这是一个普通文件。
还有其它模式,不过现在我们并不关心。

然后,使用 write-tree 命令来创建一个树对象。它会根据当前暂存区状态,计算每一个子目录的校验和,然后在 Git 仓库中这些校验和保存为树对象,并返回树的 SHA-1:

Creates a tree object using the current index. The name of the new tree

object is printed to standard output.

$ git write-tree
d8329fc1cc938780ffdd9f94e0d364e0ea74f579

这棵树 d8329fc1cc938780ffdd9f94e0d364e0ea74f579,就是我们当前暂存区的快照。

树对象的存储

看一下 .git/objects 中的内容,可以看到新增了一个对象:

$ find .git/objects -type f
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4
.git/objects/d8/329fc1cc938780ffdd9f94e0d364e0ea74f579 #新增对象
.git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a
.git/objects/83/baae61804e65cc73a7201a7252750c76066a30

我们再来查看一下它的类型和内容:

$ git cat-file -t d8329fc1cc938780ffdd9f94e0d364e0ea74f579
tree #说明它是一个树对象

$ git cat-file -p d8329fc1cc938780ffdd9f94e0d364e0ea74f579
100644 blob 83baae61804e65cc73a7201a7252750c76066a30	test.txt #它里面有一个 blob 对象

这说明快照也是被存储在 .git/objects 目录中,它是一个 tree 对象。

再创建一个树对象

接着我们来创建一个新的树对象,它包括 test.txt 文件的第二个版本,以及一个新的文件:

$ echo 'new file' > new.txt
$ git update-index --cacheinfo 100644 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a test.txt
$ git update-index test.txt
$ git update-index --add new.txt

暂存区现在包含了 test.txt 文件的新版本,和一个新文件:new.txt。

根据暂存区的内容创建一棵树(快照):

$ git write-tree
0155eb4229851634a0f03eb265b69f5a2d56f341

查看这棵树(快照)的结构:

$ git cat-file -p 0155eb4229851634a0f03eb265b69f5a2d56f341
100644 blob fa49b077972391ad58037050f2a75f74e3671e92	new.txt
100644 blob 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a	test.txt

我们注意到,新的树对象包含两条文件记录,同时 test.txt 的 SHA-1 值(1f7a7a)是先前值的第二个版本。

再来创建第三棵树

我们使用 read-tree 将第一个树对象读到暂存区,使其成为第三棵树的一个子目录。

read-tree

DESCRIPTION

Reads the tree information given by into the index, but does

not actually update any of the files it "caches".

...

OPTIONS

--prefix=

Keep the current index contents, and read the contents of the named

tree-ish under the directory at . The command will refuse to

overwrite entries that already existed in the original index file.

再通过调用 write-tree 命令,生成第三棵树。

$ git read-tree --prefix=newDir d8329fc1cc938780ffdd9f94e0d364e0ea74f579
$ git write-tree
87681ae53c6777ab971a1ca06848bf46306aa8a2

这里的 --prefix=newDir 表示子目录的名称是 newDir

我们来查看一下第三棵树:

$ git cat-file -p 87681ae53c6777ab971a1ca06848bf46306aa8a2
100644 blob fa49b077972391ad58037050f2a75f74e3671e92	new.txt
040000 tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579	newDir # 注意这个 tree
100644 blob 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a	test.txt

至此,我们创建了三棵树(三个快照),它们都被保存在 Git 数据库中。请确保理解了上面的内容,再进行下面的阅读。

Commit 对象

为什么要有 commit 对象

现在有三个树对象,分别代表了三个时刻暂存区域的快照。

三棵树 SHA-1
第一棵树 d8329fc1cc938780ffdd9f94e0d364e0ea74f579
第二棵树 0155eb4229851634a0f03eb265b69f5a2d56f341
第三棵树 87681ae53c6777ab971a1ca06848bf46306aa8a2

然而还有一个问题:若想重用这些快照,我们必须记住所有三个 SHA-1 哈希值。 并且,我们也完全不知道是谁保存了这些快照,在什么时刻保存的,以及为什么保存这些快照。

而以上这些,正是提交对象(commit object)能为你保存的基本信息。

commit 对象还可以有任意多个父提交对象。如果只有一个父提交对象,那么它是一个普通的提交对象;如果有多个父提交对象,那么它是由合并得到的提交对象;初始提交对象没有父提交对象。

创建一个提交对象

可以通过调用 commit-tree 命令创建一个提交对象,为此需要指定一个树对象的 SHA-1 值,以及该提交的父提交对象(如果有的话)。

  		commit-tree
  		
  		Creates a new commit object based on the provided tree object and emits the new commit object id  on stdout. 

我们从之前创建的第一个树对象开始:

$ echo 'first commit' | git commit-tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579
92f113cd3745d1d60ee3432d728139741ad34ebe

我们查看一下这个新的对象:

$ git cat-file -t 92f113cd3745d1d60ee3432d728139741ad34ebe
commit #这是一个 commit 对象

$ git cat-file -p 92f113cd3745d1d60ee3432d728139741ad34ebe
tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579 #当前项目的快照!
author chenjun <304114852@qq.com> 1591452459 +0800
committer chenjun <304114852@qq.com> 1591452459 +0800

first commit
再创建两个提交对象

接着,我们将创建另两个提交对象,它们分别引用各自的上一个提交(作为其父提交对象):

$ echo 'second commit' | git commit-tree 0155eb -p 92f113
520814962b564d8f57107785842a404bc530fa25
$ echo 'third commit' | git commit-tree 87681a -p 520814
d3eeae3ba52790d231ca37aadc3d0f8d3763e161

这三个提交对象分别指向之前创建的三个树对象快照中的一个。

神奇的事情

现在,神奇的事情发生了。我们对最后一个提交的 SHA-1 值运行 git log 命令,会出乎意料的发现,我们已有一个货真价实的、可由 git log 查看的 Git 提交历史了:

$ git log --stat d3eeae3ba52790d231ca37aadc3d0f8d3763e161
commit d3eeae3ba52790d231ca37aadc3d0f8d3763e161
Author: chenjun <304114852@qq.com>
Date:   Sat Jun 6 22:17:20 2020 +0800

    third commit

 newDir/test.txt | 1 +
 1 file changed, 1 insertion(+)

commit 520814962b564d8f57107785842a404bc530fa25
Author: chenjun <304114852@qq.com>
Date:   Sat Jun 6 22:16:54 2020 +0800

    second commit

 new.txt  | 1 +
 test.txt | 2 +-
 2 files changed, 2 insertions(+), 1 deletion(-)

commit 92f113cd3745d1d60ee3432d728139741ad34ebe
Author: chenjun <304114852@qq.com>
Date:   Sat Jun 6 22:13:20 2020 +0800

    first commit

 test.txt | 1 +
 1 file changed, 1 insertion(+)

我们自始至终都没有执行过 git addgit commit命令

但这就是每次我们运行 git addgit commit 命令时, Git 所做的实质工作。

Git 将被改写的文件保存为数据对象( git add ),更新暂存区( git add ),创建、记录树对象(git write-tree),最后创建一个指明了顶层树对象和父提交的提交对象( git commit-tree )。

这三种主要的 Git 对象——数据对象、树对象、提交对象——最初均以单独文件的形式保存在 .git/objects 目录下。

下面列出了目前示例目录内的所有对象,辅以各自所保存内容的注释:

$ find .git/objects -type f
.git/objects/92/f113cd3745d1d60ee3432d728139741ad34ebe # first commit
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4 # 'test content'
.git/objects/d8/329fc1cc938780ffdd9f94e0d364e0ea74f579 # tree 1
.git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a # test.txt version 2
.git/objects/87/681ae53c6777ab971a1ca06848bf46306aa8a2 # tree 3
.git/objects/01/55eb4229851634a0f03eb265b69f5a2d56f341 # tree 2
.git/objects/52/0814962b564d8f57107785842a404bc530fa25 # second commit
.git/objects/d3/eeae3ba52790d231ca37aadc3d0f8d3763e161 # third commit 
.git/objects/fa/49b077972391ad58037050f2a75f74e3671e92 # new.txt
.git/objects/83/baae61804e65cc73a7201a7252750c76066a30 # test.txt version 1

如果跟踪所有的内部指针,将得到一个类似下面的对象关系图(请忽略 commit id 不对,因为这张图是从 Git Pro 中截取的,但这不影响我们所表述的信息):

Git 数据库中的对象

深入理解暂存区、追踪与未追踪、status、diff

理解了上述内容后,我们可以对暂存区、已追踪文件与未追踪文件、git statusgit diff 等概念或指令,有更深入的理解。

暂存区

暂存区(stage area)就是 .git/index 文件,因此也被成为 index 区。

我先新创建了一个 Git 仓库 TestStage,并且往里面添加好了目录/文件:

TestStage/A/AA/a.txt
TestStage/A/AA/a2.txt
TestStage/B/b.txt
TestStage/c.txt

使用 $ git ls-files --stage 来查看 .git/index 文件,会发现它是空的:

$ git ls-files --stage # 里面是空的,所有没有任何输出

使用 $ find .git/objects 查看一下 Git 数据库中存储的内容,会发现它是空的:

$ find .git/objects # 没有存储任何数据对象
.git/objects
.git/objects/pack
.git/objects/info

现在我们执行 $ git add . 将工作区的文件添加到暂存区:

$ git add .

用我们熟悉的 $ git status 命令来看一下当前的状态,可以看到这四个文件确实被添加到了暂存区中,等待提交:

$ git status
On branch master

No commits yet

Changes to be committed:
  (use "git rm --cached <file>..." to unstage)

	new file:   A/AA/a.txt
	new file:   A/AA/a2.txt
	new file:   B/b.txt
	new file:   c.txt

那么,.git/index 文件中到底存储的是什么呢?

先使用 $ find .git/objects,可以看到 Git 数据库中多了四个对象。使用 $ git cat-file -t 可以看到它们都是 blob 对象,使用 $ git cat-file -p 可以看到它们的内容分别对应了我之前在四个文件中编辑的内容:

$ find .git/objects -type f
.git/objects/0e/9e70d1030b1d9f2a02f6143bafbe9c639cd149 # blob
.git/objects/bc/3ee700f9abf6f9805269db0af712930cc1b225 # blob
.git/objects/9f/478040b9109d4b1d25ad7f11528ed9a682f063 # blob
.git/objects/90/b451628d8449f4c47e627eb1392672e5ccec98 # blob

再使用 $ git ls-files --stage 查看暂存区的内容,可以看到 .git/index 保存了刚刚在 Git 数据库中新增的 4 个对象的 SHA-1 值,并且保存了它们各自对应的文件路径:

$ git ls-files --stage
100644 90b451628d8449f4c47e627eb1392672e5ccec98 0	A/AA/a.txt
100644 0e9e70d1030b1d9f2a02f6143bafbe9c639cd149 0	A/AA/a2.txt
100644 9f478040b9109d4b1d25ad7f11528ed9a682f063 0	B/b.txt
100644 bc3ee700f9abf6f9805269db0af712930cc1b225 0	c.txt

通过这些操作,我们可以得出结论:

暂存操作就是将文件以 blob 对象的形式保存到 Git 数据库中,并且在暂存区写入该 blob 对象的 SHA-1 值以及它对应的文件路径。

如果执行 git commit,会发现暂存区并没有被清空:

$ git commit -m 'commit msg'
[master (root-commit) cb9d59d] commit msg
 4 files changed, 4 insertions(+)
 create mode 100644 A/AA/a.txt
 create mode 100644 A/AA/a2.txt
 create mode 100644 B/b.txt
 create mode 100644 c.txt

$ git ls-files --stage
100644 90b451628d8449f4c47e627eb1392672e5ccec98 0	A/AA/a.txt
100644 0e9e70d1030b1d9f2a02f6143bafbe9c639cd149 0	A/AA/a2.txt
100644 9f478040b9109d4b1d25ad7f11528ed9a682f063 0	B/b.txt
100644 bc3ee700f9abf6f9805269db0af712930cc1b225 0	c.txt

最后,如果我们清空暂存区:

$ git rm --cached -r .
rm 'A/AA/a.txt'
rm 'A/AA/a2.txt'
rm 'B/b.txt'
rm 'c.txt'

会发现 index 文件已经被清空了,而 Git 数据库中的 blob 对象还存在:

$ git ls-files --stage
$ find .git/objects -type f
.git/objects/0e/9e70d1030b1d9f2a02f6143bafbe9c639cd149
.git/objects/bc/3ee700f9abf6f9805269db0af712930cc1b225
.git/objects/9f/478040b9109d4b1d25ad7f11528ed9a682f063
.git/objects/90/b451628d8449f4c47e627eb1392672e5ccec98

读到这里,相信你对暂存区已经有了更深入的了解。

追踪与未追踪

接着上面的 TestStage 项目,我们执行 $ git status 来查看一下当前项目的状态:

$ git status
...
Untracked files:
  (use "git add <file>..." to include in what will be committed)

	A/
	B/
	c.txt

这里会发现 A/B/ 目录和 c.txt 文件变成了未追踪状态。可我们之前明明已经用 $ git commit -m 'commit msg' 往 Git 数据库中提交过这些内容了,我们还能够找到提交记录:

$ git log
commit cb9d59d5d75c2e3c35c85df95235168ff689b115 (HEAD -> master)
Author: chenjun <304114852@qq.com>
Date:   Wed Jun 10 14:43:46 2020 +0800

    commit msg

那为什么 Git 会认为这三个目录或文件是不被跟踪的呢?Git 究竟是怎样判断文件是否被跟踪的?

结合上一步中执行的现象:$ git commit 命令执行之后,暂存区的内容并没有清空。

可以推断:Git 会根据"工作区的文件是否在暂存区存在"来判断该文件是否被跟踪。

在之前的操作中,我们其实也已经验证了这个结论。我们执行 $ git add <filename> 可以把文件从未追踪状态变成已追踪状态;执行 $ git rm --cached 把文件从暂存区移出后,文件又会变成未追踪状态,不论之前是否提交过。

status

这里笔者先简单介绍一下 HEAD,它是 .git/ 目录中的文件 .git/HEAD,一般保存的是当前的分支,也可以保存一个 commit 对象的 SHA-1。不论保存什么,都能通过它找到一个 commit 对象,然后再找到当时暂存区的快照。

$ git status 命令的输出有三部分,从上到下依次是:

$ git status 含义
第一部分 展示 暂存区HEAD 指向的快照 中存在差异的文件的路径
第二部分 展示 工作区暂存区 中存在差异的文件的路径
第三部分 展示 工作区 中 Git 没有跟踪且没有在 .gitignore 中的文件

接着上面的 TestStage 项目,我们继续来做一些操作:将A/AA/a2.txtc.txt 添加到暂存区,并且修改一下 c.txt,然后执行 $ git status

$ git add A/AA/a2.txt  c.txt

$ echo '修改了内容' >> c.txt

$ git ls-files --stage
100644 0e9e70d1030b1d9f2a02f6143bafbe9c639cd149 0	A/AA/a2.txt
100644 bc3ee700f9abf6f9805269db0af712930cc1b225 0	c.txt

$ git status
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

	deleted:    A/AA/a.txt
	deleted:    B/b.txt

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:   c.txt

Untracked files:
  (use "git add <file>..." to include in what will be committed)

	A/AA/a.txt
	B/

$ git status 的第一部分:暂存区中与 HEAD 快照相比,缺少了 A/AA/a.txtB/b.txt

第二部分:工作目录与暂存区相比,修改了 c.txt

第三部分:A/AA/a.txtB/b.txt 不在暂存区中,所以它未跟踪。

diff

这里介绍几个常用的 diff 命令,如果需要更详细的介绍,可以使用 $ git help diff 进行查看。

$ git diff 命令可以查看工作区与暂存区之间的差异。它只会比已经在暂存区中存在的文件(即以跟踪的文件)。

$ git diff
diff --git a/c.txt b/c.txt
该行显示git 版本的diff 下两个文件的对比。a版本(修改前)的文件 c.txt,和 b版本(修改后)文件 c.txt
index bc3ee70..2058a4f 100644
index后面两个数字表示两个文件的hash值(index区域的bc3ee70对象与工作区域的2058a4f对象对比)
--- a/c.txt
+++ b/c.txt
---表示修改前,+++表示修改后
@@ -1 +1,2 @@
该行表示接下来,下面显示的内容所在位置。
-表示修改前,+表示修改后;-4 表示修改前的 c.txt 文件,从第1行开始显示,一直
到第1行(上面的1为起始行)。
+1,2 则表示接下来要显示的内容为修改后的 c.txt 文件,从第1行开始显示,一直到
第2行(上面的1为起始行,2为向后偏移的行数。即显示修改前该文件第1至第2行的内容)。

 cccccc
+修改了内容

上面一共2行,为修改后的内容;前1行为修改前的内容,显示为白色字体;最后的带 + 号的
内容为修改后所增加的内容(以绿色字体显示,表示与修改前内容有区别的部分)

$ git diff <path> <path> 命令可以查看任意两文件是否有差异,具体做法是:打开一个新的终端窗口(为了避免一些问题),输入git diff,然后将两个文件依次拖进来,执行命令即可。

$ git diff --cached 比较 HEAD 指向的快照与暂存区的差异。

Git 的引用

分支

分支的实质

回忆一下 commit 对象 的结构:

commit 对象结构 含义
tree 指向的快照的 SHA-1
parent 父提交的 SHA-1
首次提交产生的提交对象没有父提交;
普通提交神圣的提交对象有一个父提交;
分支合并产生的提交对象有多个父提交。
author 创建内容的人
committer 提交的人。通常这两个是一个人,如果进行了重写
历史等操作,这两个可能会变成不一样。
commit message 提交时输入的信息

在非首次提交时,commit 对象 都会包含指向父提交的指针,顺着它我们可以找到这条线上所有的提交对象。例如在上文中所做的:

$ git log --stat d3eeae3ba52790d231ca37aadc3d0f8d3763e161
commit d3eeae3ba52790d231ca37aadc3d0f8d3763e161
Author: chenjun <304114852@qq.com>
Date:   Sat Jun 6 22:17:20 2020 +0800

    third commit

 newDir/test.txt | 1 +
 1 file changed, 1 insertion(+)

commit 520814962b564d8f57107785842a404bc530fa25
Author: chenjun <304114852@qq.com>
Date:   Sat Jun 6 22:16:54 2020 +0800

    second commit

 new.txt  | 1 +
 test.txt | 2 +-
 2 files changed, 2 insertions(+), 1 deletion(-)

commit 92f113cd3745d1d60ee3432d728139741ad34ebe
Author: chenjun <304114852@qq.com>
Date:   Sat Jun 6 22:13:20 2020 +0800

    first commit

 test.txt | 1 +
 1 file changed, 1 insertion(+)

我们找到 d3eeae3ba52790d231ca37aadc3d0f8d3763e161 ,就可以顺着它向前找,找到以它为头结点的这条线上的所有提交。也就是说,我们找到一个提交对象,实际上就可以顺着它找到一条提交线。

在实际项目的开发中,项目的 提交线 会非常多。如果我们想要随时切换线,我们需要记住每条线的最后一次提交的 SHA-1 值,并且在产生新的提交后更新这个值。

为此,我们创建一个文件来存储一条线的最后一次提交的 SHA-1 值,并且给文件起一个名称代表这条线。我们在切换线时,找到名称,就能找到文件,然后找到 commit 对象,继而找到这条线。

Git 中,这样的文件被称为引用。它们存储在 .git/refs 文件中。.git/refs 中存储的有两个目录:

文件夹名称 文件夹作用 文件的内容
heads/ 存放的是我们的分支 commit id
tags/ 存放的是我们打的tag commit id

分支存储在 .git/refs/heads 中:

$ find .git/refs -type f
.git/refs/heads/master

Git 分支的本质就是:一个指向一系列提交之首的指针或引用。在当前分支产生新提交时,会更新当前分支中存储的 SHA-1 值,即它会在每次提交中自动向前移动。

当运行类似于 git branch (branchname) 这样的命令时,Git 实际上会取得当前所在分支最新提交对应的 SHA-1 值,并将其加入创建的新引用 branchname 中。

$ git checkout <branch> 命令解释

该条命令会:

  1. 先修改 .git/HEAD 文件的内容,让 HEAD 指向 <branch>
  2. 用 HEAD 指向的快照替换暂存区的内容
  3. 用 HEAD 指向的快照替换工作区的内容
  4. 当前工作区中的修改会被保留,它们可以被提交到这个新分支上。

特别的,如果 <branch> 在本地没有找到,但是在远程仓库中有一个同名的分支,那么这条命令等价于:

$ git checkout -b <branch> --track <remote>/<branch>

HEAD

普通 HEAD

Git 是怎么知道我们当前在哪个分支呢,答案就是 .git/HEAD 文件。

HEAD 文件一般存储了当前的分支的名字,我们可以说 HEAD 指向了当前的分支:

$ cat .git/HEAD
ref: refs/heads/master

通过 HEAD 我们就能找到当前分支,然后找到当前分支指向的提交对象。

例如,一个有三次提交的 Git 仓库,当前分支是 master :

          HEAD (refers to branch 'master')
            |
            v
a---b---c  branch 'master' (refers to commit 'c')

当创建提交 d 时,Git 会让 c 作为 d 的父提交,并且让 master 指向 d,HEAD 保持不变:

              HEAD (refers to branch 'master')
                |
                v
a---b---c---d  branch 'master' (refers to commit 'd')

游离 HEAD

有时,我们不会 checkout 到一个分支上,而是会 checkout 到一个 commit 上,此时我们称它为游离 HEAD。比如执行 $ git checkout <tagname>$ git checkout <commit>$ git checkout HEAD~n 这样的命令时。

来看一个例子:

   HEAD (refers to commit 'b')
    |
    v
a---b---c---d  branch 'master' (refers to commit 'd')

此例中,HEAD 不是指向了分支,而是直接指向了 b。当我们再创建两次次提交:

        HEAD (refers to commit 'f')
         |
         v
     e---f
    /
a---b---c---d  branch 'master' (refers to commit 'd')

HEAD 也会自动更新往前走。此时我们再 checkout 到 master 分支:

               HEAD (refers to branch 'master')
     e---f      |
    /           v
a---b---c---d  branch 'master' (refers to commit 'd')

这时没有任何引用指向提交 fef 提交将会被 Git 的垃圾处理机制处理。解决办法就是在切换分支之前为 f 创建引用,比如:

$ git checkout -b foo   
$ git branch foo        
$ git tag foo           

标签

标签 Tag 其实也是一个文件,存放在 .git/refs/tags 中,文件名是 <tagname>,文件里面存储的内容是 <commitid>

在项目开发过程中,如果某次提交比较重要的话,就可以给它打上标签,通过标签就可以找到这次提交。常见的标签操作有下边这些:

$ git tag <标签> #给当前提交打标签 (HEAD指向的提交)
$ git tag <标签> <commit id> #给某个 commit 打标签
$ git tag # 列出标签
v1.0
v2.0
$ git show <tagname> # 查看标签指向的提交

$ git show v2.0
commit ab68361752bb4aa60dc7442d81cf7dbb1d294911 (tag: v3.0, tag: v2.0, master)
Author: chenjun <304114852@qq.com>
Date:   Sun Jun 7 22:05:19 2020 +0800

    second commit
...
$ git push <标签> # 推送某个标签
$ git push --tags # 推送所有标签
$ git checkout <标签> # 检出标签

细节或技巧

配置

使用 Git 之前需要配置 user.nameuer.email。这是因为每次 commit 的时候都需要这些信息,一般我们需要这样配置:

git config --global user.name myname
git config --global user.email myname@email.com

除了 --global 之外,还有 --local--system 选项,它们分别对应不同的文件:

选项 对应文件 说明
system /etc/gitconfig 包含系统上每一个用户及他们仓库的通用配置。
如果在 git config 的时候带有 --system 选项,就会读写该文件中的配置变量。
global ~/.gitconfig~/.config/git/config 只针对当前用户
local 当前仓库中的 .git/config 只针对该仓库。如果不带参数的话,默认就是该选项

每一个级别都会覆盖上一个级别的配置。

忽略文件

无需让 Git 管理的文件,我们不希望它总出现在未跟踪文件列表,也不希望被误提。

我们可以在 Git 仓库的根目录下配置 .gitignore 文件,它递归地应用到整个仓库中。

子目录下也可以有额外的 .gitignore 文件,它只作用于它所在的目录中。

这里来看一个例子:

# 忽略所有的 .a 文件
*.a
# 但跟踪所有的 lib.a,即便你在前面忽略了 .a 文件
!lib.a
# 只忽略当前目录下的 TODO 文件,而不忽略 subdir/TODO
/TODO
# 忽略任何目录下名为 build 的文件夹
build/
# 忽略 doc/notes.txt,但不忽略 doc/server/arch.txt
doc/*.txt
# 忽略 doc/ 目录及其所有子目录下的 .pdf 文件
doc/**/*.pdf

祖先引用

在上面我们有说到引用,比如分支、HEAD、Tag,它们都直接或间接指向了某次提交。都是为了帮助我们快速找到某个提交对象。祖先引用也是这样,可以帮助我们快速找到某个提交对象的祖先。

笔者创建了一个项目,然后进行了如下操作:

  1. 在 master 分支上进行了两次提交,commit message 分别是 c1c2
  2. 创建 test 分支,并在 test 分支上进行一次提交,commit message 是 c3
  3. 切换到 master 分支,在 master 分支上进行一次提交, commit message 为 c4
  4. 执行 $ git merge test,合并分支,处理冲突,commit message 为 c5

然后使用 $ git log --graph --oneline 查看提交历史:

$ git log --graph --oneline
*   c71e51d (HEAD -> master) c5
|\
| * ddea64a (test) c3
* | 1f2b362 c4
|/
* b576595 c2
* 4e14b85 c1

为了方便,笔者会用 c1 ~ c5 代表这 5 次提交。

c5 有点特殊,它有两个父提交:

$ git cat-file -p c71e51d
tree 7ac675d920660fca12a7fb50c509cafa9ffd83a1
parent 1f2b3623928bf4774dcfae22f5b49f5d3de052cf # c4
parent ddea64a2e542e4c2ae4ccfc7c7b24cfb7c753fad # c3
author chenjun <304114852@qq.com> 1591930724 +0800
committer chenjun <304114852@qq.com> 1591930724 +0800

Merge branch 'test'

其中第一个父提交时是合并时所在的分支指向的提交 c4,第二个父提交是被合并的分支指向的提交 c3

使用 $ git show 可以查看对象的信息,为了篇幅,笔者增加了 --oneline 参数,例如:

$ git show --oneline
bdfacf1 (HEAD -> master) c5

该指令等同于 $ git show --oneline HEAD

现在我们要查看 c4 的父提交,可以使用 $ git show --oneline 1f2b362^:

$ git show --oneline 1f2b362^
b576595 c2
...

也可以查看 c4 的父提交的父提交,使用 $ git show --oneline 1f2b362^

$ git show --oneline 1f2b362^^
4e14b85 c1
...

这里处理使用 1f2b362 这样的 commit id,还可以使用引用,比如:

$ git show --oneline master^ 
1f2b362 c4
...

$ git show --oneline HEAD^^
b576595 c2
...

出了再后面追加 ^ 来找父提交,还可以使用 ~,比如:

$ git show --oneline HEAD~  # or git show --oneline HEAD~1
1f2b362 c4
...

$ git show --oneline HEAD~2
b576595 c2
...

$ git show --oneline HEAD~3 
4e14b85 c1
...

Git 会根据你指定的次数获取对应的父提交。

还有一点要注意,^ 后面也可以添加数字,这种只适用于合并提交,合并提交会有多个父提交。比如合并提交的第一父提交是合并时所在分支指向的提交,第二父提交是合并时被合并分支指向的提交。

比如 c71e51d^2 代表 c2 的第二父提交,c71e51d^ 其实代表的是 c2 的第一父提交。

^^ 之后添加数字的区别就在这里。

也可以随意组合,比如: HEAD^~3^2^^^~3

提交区间

提交区间主要用来解决 "这个分支还有哪些提交尚未合并到主分支" 的问题。有三种用法,它们针对的场景各不同:

用法 作用
双点 选出在一个分支中而不在另一个分支中的提交
多点 看哪些提交被包含在某些分支中的一个,但是不在你当前的分支上
三点 选出被两个引用中的一个包含,但又不被两者同时包含的提交

储藏

为了说明这个问题,我创建了一个 TestStash 项目。并做了一些工作:

  1. 创建了一个空文件 test.txt

  2. 将该文件提交 $ git add . && git commit -m 'Initialized test.txt'

  3. 编辑 test.txt 文件,输入一些内容:

    $ cat test.txt
    1111111111
    1111111111
    
  4. 将 test.txt 添加到暂存区

    $ git add .
    
  5. 再编辑 test.txt 文件,修改一些内容:

    $ cat test.txt
    1111111111
    1111111111
    2222222222
    2222222222
    
  6. 这时我们的工作区和暂存区的状态是这样的:

    $ git status
    On branch master
    Changes to be committed:
      (use "git reset HEAD <file>..." to unstage)
    
    	modified:   test.txt
    
    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:   test.txt 
    
  7. 使用 $ git stash 命令进行贮藏:

    $ git stash
    

$ git stash 命令将工作区的内容生成一个 commit 对象保存起来,并将工作区和暂存区恢复成 HEAD 指向的快照。最新创建 stash 时生成的 commit 对象的 SHA-1 值被保存在 .git/refs/stash 文件中,之前创建的 stash 被保存在 reflog 中。

如果不想让暂存区恢复成 HEAD 指向的快照,可以增加 --keep-index 选项。

如果想让未追踪的文件也被 stash,可以增加 --include-untracked 选项。

现在工作区和暂存区都已经恢复成了 HEAD 指向的快照,即我们第一次提交时的样子。

$ cat test.txt # 空文件,没有输出
$ git ls-files -s
100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0	test.txt
$ git cat-file -p e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 # 空文件,没有输出

.git/refs/stash 中存储的 SHA-1 值是一个 commit 对象:

$ cat .git/refs/stash
87a40136b81456afa87a45dce6854fdb36f0d567

$ git cat-file -t 87a40136b81456afa87a45dce6854fdb36f0d567
commit

$ git cat-file -p 87a40136b81456afa87a45dce6854fdb36f0d567
tree 881a5afa212cb1686d8dfb9efa957564f8b5943c
parent c45be923567b27fad0388cd4147435fcc8cf4faf
parent b052fede36a385aa07898488a49813de70f7a459
author chenjun <304114852@qq.com> 1591949525 +0800
committer chenjun <304114852@qq.com> 1591949525 +0800

其中,tree 是创建 stash 时,工作区的快照。

第一父提交是创建 stash 时,HEAD 指向的 commit 对象。

$ git cat-file -p c45be923567b27fad0388cd4147435fcc8cf4faf
tree 5efb9bc29c482e023e40e0a2b3b7e49cec842034
author chenjun <304114852@qq.com> 1591949410 +0800
committer chenjun <304114852@qq.com> 1591949410 +0800

第二父提交是创建 stash 时,暂存区的快照。

$ git cat-file -p b052fede36a385aa07898488a49813de70f7a459
tree ac0229777eae54af985359244daa309fd5d5f1ce
parent c45be923567b27fad0388cd4147435fcc8cf4faf
author chenjun <304114852@qq.com> 1591949525 +0800
committer chenjun <304114852@qq.com> 1591949525 +0800

index on master: c45be92 Intialized test.txt

$ git stash pop 命令会将最新的 stash 文件指向的 commit 从栈顶删除,并且当前工作树的顶部。笔者理解,就是将 stash 文件指向的 commit 、该 commit 的第一父提交、当前工作目录的工作树做一个三方合并。

如果没有冲突,工作区目录的文件将被恢复。

如果有冲突,则 stash 不会从栈顶移除,需要手动将冲突接触,然后在 $ git stash drop 将栈顶的 stash 移除。

使用 --index 选项,Git 还需将暂存区的内容也尝试恢复。

清理工作目录

$ git clean -d -f 可以清空工作目录中所有未追踪的文件和子目录。一般我们会先使用 $ git clead -d -n,来查看一下 Git 将会删除什么文件:

$ git status
On branch master
Untracked files:
  (use "git add <file>..." to include in what will be committed)

	B/
	a.txt

$ git clean -d -n
Would remove B/
Would remove a.txt

$ git clean -d -f
Removing B/
Removing a.txt

$ git status
On branch master
nothing to commit, working tree clean

搜索

搜索方法 用途
git grep 从提交历史或者工作目录中查找一个字符串或者正则表达式,
类似于在 Xcode 中输入 cmd+shift+f
git grepackgrep 搜索速度更快,而且还搜索
任意的 Git 树
git log
日志搜索
想知道是什么 时候 存在或者引入的,比如某个字符串、某个函数。
行日志搜索功能还可以搜索某函数的历史变更,
还可以搜索单行或者某范围内容的历史变更

冲突处理

在执行 $ git merge <branch> 遇到冲突时,实际上 Git 已经做了合并,但是没有自动创建一个新的合并提交。

在解决了冲突之后执行 $ git add 暂存原本有冲突的文件,Git 就会将它们标记为冲突已解决。

处理完冲突并都添加到暂存区后,需要执行 $ git commit 来完成合并提交。

添加下面这个全局配置项,可以在处理冲突的时候,标记出分叉点的内容:

$ git config --global merge.conflictstyle diff3

重写历史

Git 可以重写已经发生的提交,使它们就像以另一种方式发生一样。这可能涉及到改变提交的顺序,改变提交中的信息或修改文件,将提交压缩或是拆分,甚至是移除。

修改最后一次提交

$ git commit --amend 

该命令会根据暂存区的内容创建一个新的提交对象,来代替 HEAD 指向的提交对象。

如果当前没有已修改的文件,那么这个命令相当于是给你了一次修改提交信息的机会。

修改多个提交信息

变基

$ git rebase 的含义是:

Reapply commits on top of another base tip

即在另一个 base 之上重演提交。

假设你有如下的提交历史,并且当前分支是 topic

      A---B---C topic
     /
D---E---F---G master

如果执行下面任何一个 rebase 指令:

$ git rebase master
$ git rebase master topic

最终的结果将是:

        A---B---C 
       /
      /       A'--B'--C' topic
     /       /
D---E---F---G master

Git 会先切换到 master 分支上,找到 topic 与 master 的分叉点,然后基于它将 topic 分支上的提交进行重演。

使用变基时一定要注意:千万不要对已经推送到服务器的提交进行变基

交互式变基

使用交互式变基工具,可以在任何想要修改的提交后停止,然后修改信息、添加文件或做任何想做的事情。

例如,如果想要修改最近三次的提交,就将作为参数传递给 $ git rebase -i,即 HEAD~3。实际上 HEAD~3 指定了四次提交,也就是说这个 base 是最近第三次提交的父提交。这个命令会将 (HEAD~3, HEAD] 范围内每个提交都重写,不论是否修改信息。

为了演示,我创建了一个 TestRebase 项目,并创建了一个 test.txt 文件,创建了七次提交,每次提交在test.txt文件中追加一行提交次数对应的数字,与提交次数对应。最终文件的内容与提交历史如下:

$ cat test.txt
1
2
3
4
5
6
7

$ git log --graph --oneline
* 883ed2f (HEAD -> master) C7
* 83c6464 C6
* 9234113 C5
* 113cc79 C4
* da899ed C3
* d34e046 C2
* f7322c4 C1

现在我想要修改C5C7的提交信息。执行 $ git rebase -i HEAD~3,将会显示出这样一个界面:

$ git rebase -i HEAD~3
pick 9234113 C5
pick 83c6464 C6
pick 883ed2f C7

# Rebase ca7704a..6d59c0f onto ca7704a (3 commands)
...

可以看到它的顺序是 C5C6C7,这代表这着Git将要重演的顺序。

我们把 C5 和 C7 前面的 pick 改成 edit,表示将要对它们进行修改。然后保存退出。可以看到终端显示如下:

$ git rebase -i HEAD~3
Stopped at 9234113...  C5
You can amend the commit now, with

  git commit --amend

Once you are satisfied with your changes, run

  git rebase --continue

你可以停下来看看,现在 HEAD 是指向 C5 的,C5 也被检出到了暂存区和工作目录。

现在,我们正在对 C5 进行重演。使用$ git commit --amend命令来修改提交信息:

$ git commit --amend

终端将显示一个编辑界面,我们将提交信息修改,然后保存退出:

C5 C5 修改了 C5 的提交信息
# Please enter the commit message for your changes. Lines starting
...

然后查看提交历史:

$ git log --oneline --graph
* c93db91 (HEAD) C5 C5 修改了 C5 的提交信息
* 113cc79 C4
* da899ed C3
* d34e046 C2
* f7322c4 C1

可以看到,原 C5 的提交 9234113 被修改成了 c93db91,新提交 c93db91 的 commit msg 是 C5 修改了提交内容和信息

我们执行 $ git rebase --continue ,表示对 C5 的重演已经结束。继续进行我们的交互式变基:

$ git rebase --continue
Stopped at 883ed2f...  C7
You can amend the commit now, with

  git commit --amend

Once you are satisfied with your changes, run

  git rebase --continue

可以看到我们直接跳过 C6 来到了 C7,现在正在对 C7 进行重演,我们继续使用 $ git commit --amend 来修改提交信息:

$ git commit --amend

C7 修改了 C7 的提交信息
# Please enter the commit message for your changes. Lines starting
...

然后执行 $ git rebase --continue 表示 C7 重演完成。

$ git rebase --continue
Successfully rebased and updated refs/heads/master.

我们交互式变基已经成功,并且 masterHEAD 都被更新到我们新创建的 C7 提交 106144e 上了。再来查看一下提交的历史,可以发现提交信息都已经被修改了:

$ git log --oneline --graph
* 106144e (HEAD -> master) C7 修改了 C7 的提交信息
* 4968afb C6
* c93db91 C5 修改了 C5 的提交信息
* 113cc79 C4
* da899ed C3
* d34e046 C2
* f7322c4 C1

到这里,我们已经学会了使用交互式变基来修改提交历史。如果想在修改历史提交信息的时候同时修改文件的内容,也是可以的,只不过可能会遇到冲突,只需要处理好冲突然后根据终端给出的提示继续进行就可以了!

删除提交历史

现在我们想在上次的基础上删除 C4 这条提交记录,那么我们执行 $ git rebase -i 113cc79^

$ git rebase -i 113cc79^
pick 113cc79 C4
pick c93db91 C5 修改了 C5 的提交信息
pick 83c6464 C6
pick 106144e C7 修改了 C7 的提交信息

将 C4 这条记录删除,保存退出:

pick c93db91 C5 修改了 C5 的提交信息
pick 83c6464 C6
pick 106144e C7 修改了 C7 的提交信息

在本例中我们会遇到冲突,处理好冲突之后按照 Git 的提示继续进行变基即可。最终我们查看提交历史:

$ git log --oneline --graph
* 08c005f (HEAD -> master) C7 修改了 C7 的提交信息
* 333b7d5 C6
* fa91936 C5 修改了 C5 的提交信息
* da899ed C3
* d34e046 C2
* f7322c4 C1

可以看到 C4 被删除了,并且 C5、C6、C7 也被重演成了新的提交!

修改提交顺序

执行 $ git rebase -i 之后,也可以调整文本编辑器中提交记录的顺序。Git 会按照你编辑的顺序来进行重演。

压缩提交

现在我们想将上述历史中的 C5 ~ C7 合并成一次提交。执行$ git rebase -i HEAD~3

$ git rebase -i HEAD~3
pick fa91936 C5 修改了 C5 的提交信息
pick 333b7d5 C6
pick 08c005f C7 修改了 C7 的提交信息

修改变基脚本:

pick fa91936 C5 修改了 C5 的提交信息
squash 333b7d5 C6
squash 08c005f C7 修改了 C7 的提交信息

然后保存退出,Git 会将所有三次提交信息放到编辑器中来让我们合并:

# This is a combination of 3 commits.
# This is the 1st commit message:

C5 修改了 C5 的提交信息

# This is the commit message #2:

C6

# This is the commit message #3:

C7 修改了 C7 的提交信息

我们再对它进行修改:

C5 ~ C7

然后保存并退出,可以看到已经完成了压缩提交:

$ git rebase -i HEAD~3
[detached HEAD bc06b4b] C5 ~ C7
 Date: Sun Jun 14 16:06:17 2020 +0800
 1 file changed, 3 insertions(+)
Successfully rebased and updated refs/heads/master.

现在来查看提交历史:

$ git log --graph --oneline
* bc06b4b (HEAD -> master) C5 ~ C7
* da899ed C3
* d34e046 C2
* f7322c4 C1

我们已经将 C5C6C7 压缩成了一次提交 C5 ~ C7。查看 bc06b4b 提交的信息:

$ git show bc06b4b
commit bc06b4be7537c3bd3b624001feea9eeb8afa3de1 (HEAD -> master)
Author: chenjun <cjfirstmail@163.com>
Date:   Sun Jun 14 16:06:17 2020 +0800

    C5 ~ C7

diff --git a/test.txt b/test.txt
index 01e79c3..6275511 100644
--- a/test.txt
+++ b/test.txt
@@ -1,3 +1,6 @@
 1
 2
 3
+5
+6
+7

reset 与 checkout

reset

$ git reset 指令一般会有三步,默认是执行到第二步:

  1. 移动 HEAD 指向的分支(使用 --soft 选项,则到此停止)
  2. 让暂存区看起来更像 HEAD 指向的快照(默认选项 --mixed,执行到这一步)
  3. 让工作目录看起来更像暂存区(使用 --hard 选项,则会执行到这一步)

和之前一样,我创建一个 TestReset 来演示这个命令,并创建了三次提交,然后查看它的提交历史和文件内容:

$ git init TestReset && cd TestReset
$ echo '1' >> test.txt && git add . && git commit -m C1
$ echo '2' >> test.txt && git add . && git commit -m C2
$ echo '3' >> test.txt && git add . && git commit -m C3

$ git log --oneline --graph
* 5d507ce (HEAD -> master) C3
* 64b3122 C2
* 77c926b C1

$ cat test.txt
1
2
3

现在我修改了 test.txt,添加了一句话add something...

$ cat test.txt
1
2
3
add something...

执行 $ git status 将看到项目的状态是 test.txt 文件已被修改,待添加到暂存区:

$ 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:   test.txt

no changes added to commit (use "git add" and/or "git commit -a")

执行 $ git add 指令将它添加到暂存区:

$ git add .
$ git status
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

	modified:   test.txt

这时我们如果执行 $ git reset 指令,会将 HEAD 指向的快照更新到暂存区。因此,test.txt 又将变回待暂存的状态:

$ git reset
Unstaged changes after reset:
M	test.txt

$ 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:   test.txt

no changes added to commit (use "git add" and/or "git commit -a")

我们对修改进行一次提交:

$ git add .
$ git commit -m 提交了 something
$ git log --oneline --graph
* b17eb7e (HEAD -> master) 提交了something
* 5d507ce C3
* 64b3122 C2
* 77c926b C1

我们执行 $ git reset --soft HEAD^,则 master 会指向 C3:

$ git reset --soft HEAD^
$ git log --oneline --graph
* 5d507ce (HEAD -> master) C3
* 64b3122 C2
* 77c926b C1

test.txt 也会随之变成已暂存、待提交状态:

$ git status
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

	modified:   test.txt

现在,我们再来执行 $ git reset --hard HEAD^^,那么 master 会指向 C1,然后暂存区也会被变成 C1 的快照,工作目录也会变回 C1:

$ git reset --hard HEAD^^
HEAD is now at 77c926b C1
$ git log --oneline --graph
* 77c926b (HEAD -> master) C1
$ git status
On branch master
nothing to commit, working tree clean
$ cat test.txt
1

$ git reset 不光可以对指定的 commit 进行 reset,还可以指定文件的路径来重置某个文件,具体原理和上面所说的都相同。

checkout

$ git checkoutgit reset 很像,不过它们有两点区别:

首先不同于 reset --hardcheckout 对工作目录是安全的,它会通过检查来确保不会将已更改的文件吹走。 其实它还更聪明一些。它会在工作目录中先试着简单合并一下。 而 reset --hard 则会不做检查就全面地替换所有东西。

第二个重要的区别是如何更新 HEAD。 reset 会移动 HEAD 分支的指向,而 checkout 只会移动 HEAD 自身来指向另一个分支。

参考文章:Pro Git