Git教程:提交

Posted by lili on

本文介绍Git的文件管理和Index

目录

提交

在Git里,一个提交代表对于repo的一次修改。当Git做一次提交的时候,它会对Index做一个快照(snapshot),并且把这个快照存到object pool里。它会把这个快照和上一个快照进行diff,从而计算出发生了哪些改变,那些没有变化的对象是可以复用的,而发生变化的对象会重新生成,但是前面也介绍过Git逻辑上两个版本是独立的,但是在pack文件的存储上实际会复用原来的版本,因此”实际”上存储的也是diff。虽然看起来要diff两个版本的所有文件看起来工作量比较大,但是因为Git的每个文件(内容)都是和一个SHA1一一对应,因此diff的实际速度非常快。

原子的变更集(Atomic Changesets)

和数据库的事务类似,Git的一次提交是原子的,也就是所有的修改要么完全成功,要么不发生任何变化(失败)。

图:git commit 之后的状态

提交的标识

不过是个人开发还是团队协助,标识一个提交都很重要。比如我们需要创建一个分支,我们得告诉Git从那个提交(repo的状态或者snapshot)创建,我们要diff两个版本的差异,我们需要告诉Git是哪两个版本。我们之前也看到了一些显式和隐式应用提交的方法:比如使用40个16进制字母的SHA1是一种显式的引用方法;而HEAD是隐式的方式,此外还有很多其它的引用方法。没有哪种方法是最优的,我们需要在不同的场合使用不同的方法。

绝对的提交名字

最严格准确的就是SHA1的Hash,它在任何上下文都是唯一的表示这个特点的提交。并且这个SHA1不只是在某个repo里唯一,而是在整个Git的世界里是唯一的,类似于UUID。因为40位的SHA1太长,很多git命令允许我们在没有冲突的情况下使用一个较短的SHA1的前缀。具体这个前缀要多长呢?一般的经验是6位就够了:

$ git log -1 --pretty=oneline HEAD
1fbb58b4153e90eda08c2b022ee32d90729582e6 Merge git://repo.or.cz/git-gui
$ git log -1 --pretty=oneline 1fbb
error: short SHA1 1fbb is ambiguous.
fatal: ambiguous argument '1fbb': unknown revision or path
not in the working tree.
Use '--' to separate paths from revisions
$ git log -1 --pretty=oneline 1fbb58
1fbb58b4153e90eda08c2b022ee32d90729582e6 Merge git://repo.or.cz/git-gui

ref和symref

如果每次使用Git的命令都需要记住对象的Hash,这会非常麻烦。为了解决这个麻烦,Git提供了引用(Reference,ref)。一个ref就是.git/refs目录(或者子目录)下的一个文件,这个文件只有一行——内容是某个对象的Hash。下面我们通过一个简单的例子来学习一下:

$ mkdir /tmp/git-ref
$ cd /tmp/git-ref/
git-ref$ ll
总用量 144
drwxrwxr-x  2 lili lili   4096 2月  10 09:01 ./
drwxrwxrwt 15 root root 139264 2月  10 09:02 ../
git-ref$ git init
初始化空的 Git 仓库于 /tmp/git-ref/.git/
git-ref$ echo "hello" > hello
git-ref$ git add hello 
git-ref$ git commit -m "add hello"
[master (根提交) e5af2b7] add hello
 1 file changed, 1 insertion(+)
 create mode 100644 hello
git-ref$ echo "world" > world
git-ref$ git add world 
git-ref$ git commit -m "add world"
[master 2e1b0c1] add world
 1 file changed, 1 insertion(+)
 create mode 100644 world
git-ref$ git checkout -b test_branch
切换到一个新分支 'test_branch'
git-ref$ echo "branch file" > branch_file
git-ref$ git add branch_file 
git-ref$ git commit -m "add branch file"
[test_branch 7dee619] add branch file
 1 file changed, 1 insertion(+)
 create mode 100644 branch_file

$ git tag v1.0 e5af2b705233e1e028034078894db33555986fb2

上面的命令在master分支创建了hello和world两个文件,然后拉出一个分支test_branch,然后在这个分支里添加test_branch文件,并且给hello的那个commit打上一个v1.0的tag。我们用git log来确认一下上面的操作:

$ git log
commit 7dee61907c246439d87f2aacf183bc072c8114f0
Author: lili <fancyerii@gmail.com>
Date:   Mon Feb 10 09:03:45 2020 +0800

    add branch file

commit 2e1b0c1e43d389cb6b0119b6b8c722254a65d9c4
Author: lili <fancyerii@gmail.com>
Date:   Mon Feb 10 09:02:44 2020 +0800

    add world

commit e5af2b705233e1e028034078894db33555986fb2
Author: lili <fancyerii@gmail.com>
Date:   Mon Feb 10 09:02:28 2020 +0800

    add hello

Git有三种ref:refs/heads下的每一个文件对于一个本地的Topic分支(Topic分支就是我们最常见开发用的分支);.git/refs/remotes下的远程(remote)分支;.git/refs/tags下的tag。我们来看一下:

$ cat .git/refs/heads/master 
2e1b0c1e43d389cb6b0119b6b8c722254a65d9c4
$ cat .git/refs/heads/test_branch 
7dee61907c246439d87f2aacf183bc072c8114f0
$ cat .git/refs/tags/v1.0 
e5af2b705233e1e028034078894db33555986fb2

读者可以把上面的结果和git log比较,验证这个两个分支的ref是否指向分支最近的提交(tip)。注意:我们不需要自己维护这些ref,我们在使用git commit等命令时git会自己维护这些文件。这里我们还没有介绍远程分支,远程分支是类似这样的:

$ cat .git/refs/remotes/origin/master 
08c423341908980da8b431469c4f21902c53b630

远程分支在.git/refs/remotes,并且远程repo会有一个名字,比如origin,后面我们会介绍。

除了ref,Git还有symbolic ref(symref),其实就是指向引用的引用:

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

比如HEAD就是一个symref,它的含义是当前分支的最近提交(tip),因为它的值依赖于当前所在分支,所以Git会把它的内容存储为一个ref。如果和C语言类比的话,ref就是指针,指向一个对象,而symref是指向指针的指针。比如上面的.git/HEAD就指向refs/heads/test_branch这个ref。我们可以切换到master:

$ git checkout master 
切换到分支 'master'
$ cat .git/HEAD 
ref: refs/heads/master

我们发现HEAD这时候就被改成指向master这个ref了。下面是一些经常用的的symref:

  • HEAD

HEAD总是指向当前分支的最近一次提交。

  • ORIG_HEAD

某些命令,比如merge和reset,会在ORIG_HEAD里记录这个命令之前的HEAD,这样你可以使用这个symref恢复或者revert到上一版本。

  • FETCH_HEAD

When remote repositories are used, git fetch records the heads of all branches fetched in the file .git/FETCH_HEAD. FETCH_HEAD is a shorthand for the head of the last branch fetched and is valid only immediately after a fetch operation. Using this symref, you can find the HEAD of commits from git fetch even if an anonymous fetch that doesn’t specifically name a branch is used. The fetch operation is covered in Chapter 12.

当使用远程(remote)repo时,git fetch命令会在.git/FETCH_HEAD里记录所有分支的head。FETCH_HEAD只是在git fetch后有效,后面会介绍fetch命令。

  • MERGE_HEAD

在git merge的过程中,MERGE_HEAD会记录所有参与合并的分支。

相对名字

除了绝对名字、ref和symref,Git还提供了相对的名字,这些相对的名字通常是相对于某个ref(比如分支的HEAD)而言的。

比如master^表示master分支的倒数第二个提交,此外我们还可以使用:master^^、master~2甚至更复杂的master~10^2~2^2。除了repo的初始(第一个)提交,其它的提交至少有一个parent。普通的提交只有一个parent,而merge可能有多个parent。如果有多个parent,我们可以用^来选择哪个parent。比如^1(可以省略1变成^)就是第一个parent,^2是第二个parent,……。

图:多个parent的相对名字

比如上图的提交C是一个merge,它有3个parent,则^1、^2和^3分别代表三个parent的提交。

除了用^每次往前一个提交,我们还可以用~一次回溯多个parent,比如C~1、C~2和C~3分别表示C的parent,C的parent的parent和C的C的parent的parent的parent。如果一个提交有多个parent,则~会回溯第一个parent。

图:使用~回溯

如上图所示C~1表示C的第一个parent,而C~2表示C的第一个parent的第一个parent。我们可以省略数字1,因此C^1^1可以写成C^^,它等价于C~2(C第一个parent的第一个parent)。Git的一些命令也会用这种相对名字,比如:

$ git show-branch --more=10 master 
[master] add world
[master^] add hello

上面的show-branch会线上master的最近10个提交,它并没有显示绝对的SHA1 ID,而是用的master^。

提交的历史

查看历史的提交

查看历史的最常用命令是git log,它有很多想选项来控制格式、颜色等等。不带任何参数的git log等价于git log HEAD,也就是逆序查看所有从HEAD可达的提交。这大致会是时间的降序排列,但是如果有分支的合并的话,并不能完全保证这种时间降序关系,更准确的说应该是图的拓扑排序。

如果使用git log commit-name,这里的commit-name可以是绝对的名字或者相对的名字,则是从这个提交开始往上回溯。我们也可以知道要查看的范围,比如:

$ git log --pretty=short --abbrev-commit master~12..master~10
commit 6d9878c...
Author: Jeff King <peff@peff.net>
clone: bsd shell portability fix
commit 30684df...
Author: Jeff King <peff@peff.net>
t5000: tar portability fix

上面的命令会查看master~12(不包含)到master~10(包含)之间的提交,它包含master~11和master~12两个提交。..表示范围,后面我们会详细介绍。上面的命令也使用了--pretty=short和--abbrev-commit两个选项。--pretty选项调节每个提交的输出信息多少,可选项包括oneline、short和full。--abbrev-commit会输出省略的SHA1b,比如”6d9878c…“。

-p选项可以输出每个提交的diff,比如:

$ git log -1 -p
commit 2e1b0c1e43d389cb6b0119b6b8c722254a65d9c4
Author: lili <fancyerii@gmail.com>
Date:   Mon Feb 10 09:02:44 2020 +0800

    add world

diff --git a/world b/world
new file mode 100644
index 0000000..cc628cc
--- /dev/null
+++ b/world
@@ -0,0 +1 @@
+world

此外上面的-1告诉git log只输出一个提交,我们也可以把-1换成任意的-n。

--stat选项遍历所有修改的文章,统计每个文件修改的行数:

$ git log --pretty=short --stat
commit 2e1b0c1e43d389cb6b0119b6b8c722254a65d9c4
Author: lili <fancyerii@gmail.com>

    add world

 world | 1 +
 1 file changed, 1 insertion(+)

commit e5af2b705233e1e028034078894db33555986fb2
Author: lili <fancyerii@gmail.com>

    add hello

 hello | 1 +
 1 file changed, 1 insertion(+)

另外一个查看提交的方法是git show,比如:

$ git show HEAD~1
commit e5af2b705233e1e028034078894db33555986fb2
Author: lili <fancyerii@gmail.com>
Date:   Mon Feb 10 09:02:28 2020 +0800

    add hello

diff --git a/hello b/hello
new file mode 100644
index 0000000..ce01362
--- /dev/null
+++ b/hello
@@ -0,0 +1 @@
+hello

除了查看提交,git show也可以查看某个blob,比如:

$ git show test_branch:hello 
hello

上面的命令用git show查看test_branch分支的hello文件。

提交图(Commit Graph)

上一部分我们用图示的方式来展示了一个repo的状态,但那是非常简单示例,实际的repo的历史非常复制,下面是一个简化版本的图示:

图:完整的提交图

因为每个提交都包含一个树对象,因此在表示提交的顺序是忽略这个树对象,如下图所示:

图:简化的提交图

上图和前面的提交图是对应的,但是省略了每个提交的树对象。这个图是有向无环图(DAG),而且是连通的,因此从任意一个点出发最终都会走到初始提交。为了讨论方便,我们给每个提交(节点)起一个名字,如下图所示:

图:带标签提交图

我们可以把A、B、C等看成提交的SHA1,不过为了讨论方便,我们使用很简短的名字。这个图从左到右大致是时间的前后关系,比如A提交发生在B之前,而C在B之后,但是C和E的时间前后是不确定的。事实上Git根本不关心提交的时间,我们完全可以修改系统时间,让后面的提交事情比parent的提交时间还早,比如我们设置系统时间为前天,然后创建一个文件并且提交:

$ git log
commit 9394ee04fa8d4ad3f552923e0744c70b2187d6fe
Author: lili <fancyerii@gmail.com>
Date:   Sat Feb 8 19:24:54 2020 +0800

    bad time

commit b143c28471a42f282c992af1ee72b97f93ec42e4
Author: lili <fancyerii@gmail.com>
Date:   Mon Feb 10 18:03:48 2020 +0800

    add line

commit 2e1b0c1e43d389cb6b0119b6b8c722254a65d9c4
Author: lili <fancyerii@gmail.com>
Date:   Mon Feb 10 09:02:44 2020 +0800

    add world

commit e5af2b705233e1e028034078894db33555986fb2
Author: lili <fancyerii@gmail.com>
Date:   Mon Feb 10 09:02:28 2020 +0800

    add hello

我们发现最后一次提交的时间(2/8)要比第一次提交(2/10)还有早!为什么Git要忽略时间呢?因为对于一个非中心化的系统来说,要获得同步的时钟是非常困难的。

我们还可以更进一步的简化:去掉边的箭头,通过点(提交)的横坐标的大小来判断方向:

图:没有箭头的提交图

怎么判断master和pr-17谁是谁的parent呢?因为从水平方向(横坐标)来说,master在右边,因此pr-17是master的parent。

提交区间(commit range)

一些Git的命令需要提供一个提交区间,比如前面的git log和git diff。提交区间的写法为”start..end”,其中start是开始的提交名字(绝对或者相对或者ref/symref);很多end是区间的结束。比如前面的例子master~12..master~10表示倒数第11个(A)和倒数第12个(B)提交:

图:线性的提交历史

读者可能会猜测start..end就是从start到end的这(end-start)个提交,它不包含start,但是包含end。对于线性的链式的(每个提交只有一个parent)历史来说,这个说法是正确的,但是如果某个提交有多个parent(比如merge),则没有这么简单!

start..end的定义是:从end出发回溯所有可达的提交去掉从start出发回溯所有可达的(reachable)提交。当我们使用git log Y,它返回的是从Y开始所有可达的提交,我们也可以用^X排除所有从X可达的提交,因此”git ^X Y”和”git X..Y”是等价的。因此我们可以把提交区间看出两个集合的差集,下面我们用严格的定义来看前面线性的例子:

图:把提交范围看出集合的差

上图演示了M~12..M~10,图的上面是M~12所有可达的提交,而中间的图是M~10开始所有可达的提交,它们的”差”就是A(M~11)和B(M~10)两个提交。

下面我们来看一个复杂一点的图:

图:master被merge到topic分支

上图显示的是master分支的V提交被merge到topic分支里。topic..master表示W、X、Y和Z这4个提交。因为master可达的是T、U、V、W、X、Y和Z,而topic可达的包括A、B、C、D和T、U、V,所以前面那个集合减去后面的集合得到的就是W、X、Y和Z。

下图是topic被merge到master分支:

图:topic被merge到master分支

在这个图里,topic..master表示V 、W 、X 、Y和Z这几个提交。因为master可达的是所有的提交,topic可达的是A和B,它们的差就是前面那5个提交。

我们再看一个有分支与合并的例子:

图:分支与合并

在上图里topic..master只包含W、X、Y和Z。

定位提交

git bisect

git bisect类似二分查找的某个”坏”的提交。比如我们在提交为….A,C1,C2,….,CN,B,…..,我们发现提交B出现了问题,而且我们知道提交A是好的,当然我们可以逐一从C1,C2,….开始寻找第一个”坏”的提交,但是这很费时间。我们可以用类似”二分”查找的方法来寻找这个”坏”的提交:我们先看A和B中间的某个提交CK,如果它是好的,则坏的出现在区间C{K+1}和B之间;如果它是坏的,则坏的在C1和CK之间。通过这样二分查找,我们可以把这个查找从O(n)的复杂度变成O(logn)。当然Git无法判断哪个提交是好的还是坏的,这需要执行命令的人来判断。具体的用法这里就不介绍了,感兴趣的读者可以阅读文档

git blame

git blame是个很有用的命令,可以详细的记录一个文件的某一行是哪个版本引入的,比如:

$ git blame hello
^e5af2b7 (lili 2020-02-10 09:02:28 +0800 1) hello
b143c284 (lili 2020-02-10 18:03:48 +0800 2) hellooooo

hello文件有两行,第一行是e5af2b7这个提交加入的,而第二行是b143c284加入的。