天天看点

当执行Git命令时,Git做了什么

Git存储类型主要有4种,blob,tree,commit,tag,他们以压缩的形式存储在.git/objects目录当中。其中blob和tree构成其完整的文件存储系统,类似于操作系统的文件系统。commit和tag则维护提交和标记等信息。

在Git的存储模型当中有一个原则,只要类型和内容相同,就会被认定为同一个文件,无论在工作区当中文件有多少份拷贝,在git库里面只会存有一份,并以其类型,大小和内容串接后的内容结果的散列值作为其文件名。其格式为:

#TYPE#

#SPACE#

\0

#SIZE#

#CONTENT#

其读写规则如下面的Ruby程序所示:

git blob 是 git 最基础的存储类型,类似于文件系统中的文件,可以表示存储任意的文件,比如文本文件,源代码,图片等,对应文件系统中的标准文件。

如下图所示,blob文件只记录类型(blob),长度,二进制内容。并不关心其真实的存储位置和具体的文件名,如果有多个文件内容完全一致,但是路径或文件名不一致,在GIT的库中对应的都是同一个blob文件。

tree 类似于文件系统中的目录,他能指向或包含:

git blob 对象记录

其他 git tree 对象记录

同blob一样,tree同样只记录它下面包含的直接子目录和文件,不关心自己的名称,它自己的名称记录在自己的parent tree当中。如下图所示的文件结构,tree1,tree2,tree3指的都是同一颗tree。

git commit对象存储的是每一次提交的内容,它包含:

谁创建了这个提交,一般包含用户名和Email及提交信息等内容。

指向根git tree对象的一个指针.

指向父git commit对象的一个指针,可以有多个,一般为一个,merge代码的时候会出现两个,也可以没有(只限首次提交的情况)。

本次提交的注释或备注。

git tag对象比较特殊,相当于别名的作用,我们可以使用它对commit,tree,blob对象创建别名。一般情况下,如果我们不写tag的描述,则不会在.git/objects目录中新增tag object对象,只会在.git/refs/tags下新增一个ref的记录。只有在tag时指定了-m并填写了描述信息时,它才会真正生成一个git tag对象。它一般包如下信息:

所指向对象的HASH值。

所指向对象的类型。

tag名称。

tagger(tag创建者及时间)。

描述信息。

大家都清楚,Git本地库有3个组成部分,分别是工作目录(Working Directory),暂存区(Stage或者Index),Git仓库,其中,工作目录和Git仓库都比较好理解,和其他的版本控制工具非常类似,要理解暂存区就要稍微花点心思了。

Git的设计确实非常巧妙,所有暂存区信息的维护只跟一个文件有关,那就是 <code>.git/index</code>,和工作区和Git仓库不同,<code>.git/index</code>只维护 blob 对象的信息,并不关心 tree 或者 commit。我们可以使用命令 <code>git ls-files —stage</code> 查看暂存区的状态。

放到暂存区的文件都已经添加到.git/objects中,执行commit时,就是把暂存区中的变化提交到git库中。对比暂存区和库中的文件,可以很容易区分哪些文件有了变化。暂存区中有,而库中没有,则表示下次commit需要新增文件。暂存区中没有,而库中有,则表示下次commit需要删除某些文件。如果暂存区中有,库中也有,但是其HASH不一样,则表示下次commit需要修改文件。暂存区中有,库中也有,名称和HASH值都相同,则表示当前提交中没有任何更新。

一般我们执行 <code>git add —all</code> 命令,可以批量把工作目录的所有更新都添加到暂存区当中,执行 <code>git status</code> 可以很容易查看暂存区中和工作目录中的文件变化。同和git库比较一样,无论是文件内容变化了,还是文件名或者文件路径变化了,都会被视作更新。

注意,.git/index中只track blob对象,它其实不关心tree对象的,这也正是暂存区设计精巧之处,因为只要是blob对象变化了,它的parent tree一定会相应变化,所以tree的变化只要在commit时自动更新就好了。 如果删除.git/index , 等价于下次commit删除整个库中的文件。

首先,创建一个空目录并进到该目录,执行 <code>git init</code> 初始化git本地库。执行完后,你会发现,当前目录下多出了一个.git的隐藏文件夹,如下所示:

git的所有命令操作都是围绕着.git目录下的内容展开,我们的每一步操作,都可能会引起该目录中内容的变化,下面我们来边操作边观察该目录的变化情况。

下面,我们先新增一个文件,并把它存储到暂存区。

可以发现,这时.git/objects下多了一个文件。

通过命令查看,该文件果然是刚刚添加的test.txt。

使用命令 <code>git commit -m 'first commit'</code> 创建第一个提交。

这时,我们发现 .git/objects 目录中又新增了2个文件。

通过命令查看,可以发现,一个文件是tree对象,另一个是commit对象。

commit 对象的散列值会因人而异,即便是同一个人,不同的时间提交的commit对象也会不一样。

<code>.git/logs/HEAD</code> 会记录下你每一次的更新操作,比如提交动作或者切换分支的动作,如果只在单分支下操作,<code>.git/logs/HEAD</code> 中的内容和 <code>.git/logs/refs/heads/master</code>一般都会同步更新,完全一致。而 <code>.git/refs/heads/master</code> 则指向于当前分支下最新的提交。

修改test.txt,并且再新增一个new.txt。

添加到暂存区后,会发现 <code>.git/objects</code> 下又多了2个文件。

通过命令查看可以看到最新添加的2个文件内容,正是新增的一个文件new.txt和修改过后的test.txt中的文件内容。

注意:内容为 "version 1" 的blob对象还在。尽管我们操作是在同一文件中进行,只要是内容不同,git都会认为是不同的文件,如果你碰巧文件内容和其他文件内容相同,则git不会创建新的文件,比如:new.txt中恰好也是 "version 1",则不会有新的文件增加,感兴趣的同学可以试一试。

使用命令 <code>git commit -m 'second commit'</code> 提交该变更,我们会发现,<code>.git/objects</code> 目录中又新增了2个文件,跟第一次的提交一样,一个是commit对象,另一个是tree对象。

如果我们想找回某个文件的历史版本,可以使用 <code>reset</code> 命令找回。

恢复test.txt到历史版本

我们把找回后的test.txt移动到 bak 目录,并把根目录下的test.txt恢复最新版本

把变更提交到暂存区后,我们惊异地发现,<code>.git/objects</code> 目录下竟没有任何变化。这是因为,尽管有更新,但是我们更新的文件内容在 <code>.git/objects</code> 目录下都已经存在了,故无需重新创建。然而,尽管blob对象没有变化,但是tree对象的变化还是很大的,但是由于我们还没有提交这个变更,故tree对象还没有创建。

新的tree对象的创建一般发生在commit提交之时。

使用 <code>git commit -m 'third commit'</code> 提交当前的变更。

同样,这一次提交后,新增了2个对象,分别是当前的commit对象,和最新的tree对象。

当前提交的的状态

历史提交的状态

使用命令 <code>git checkout -b dev</code> 切换到dev分支

可以看到 <code>.git/objects</code> 目录中多了 <code>.git/logs/refs/heads/dev</code>,<code>.git/refs/heads/dev</code> 两个文件,前者记录了当前分支下的操作日志,后者维护了当前分支最新commit的ref,即当前最新的commit值。通过checkout切换分支,本质上是改变 <code>.git/HEAD</code> 中的值。

我们再使用命令 <code>git checkout master</code> 切换回master分支。

可以看出,这次切换,.git 目录中的内容变化并不大,除了<code>.git/HEAD</code>中的ref指向了master。

在master分支下提交变更后,<code>.git/logs/refs/heads/dev</code> 和 <code>.git/refs/heads/dev</code> 中的内容并不会有任何变化。

同样,在dev下做任何变更,也不会影响 <code>.git/logs/refs/heads/master</code> 和 <code>.git/refs/heads/master</code> 中的内容。

注意,通过merge的合并后的commit,它会有2个parent,整个提交历史会形成一个菱形。
rebase 同样可以合并2个分支,但是和merge不同,拿 <code>git rebase master</code> 举例,rebase操作以master为基准,对于和master不同的commit,它会单独拿出来并重做这些commit,这样整个提交历史依然会是一条直线。
原来的提交5,6并没有丢失,只不过换做7,8重做了一次,由于是重做,时间变了,即便消息内容一样,commit的散列值也变化了。

合并分支时,merge和rebase究竟选哪一个一直都存有争论,在这里并不像展开这些争论,只在此列出它们各自的优点(对方的劣势),供大家参考。

merge 的优点

完整的记录所有提交的先后时间顺序。

不会修改提交的散列值。

在团队协作过程中,冲突解决比较容易。

rebase 的优点

提交历史记录线性,非常干净。

合并后commit非常干净,不会存在像merge那样合并时commit中一大堆文件更新的情况。

如果团队没有要求,大家按照自己的习惯去使用就好,不用太纠结。另外,如果本地当前分支的更新已经提交到远端库,则千万不要使用rebase,因为rebase会修改commit的散列值,会给其他协作者造成难以解决的冲突。

Git 有超过100多个子命令,每个子命令又有不同的参数,而且很多时候,不同参数下的子命令操作的语义相差甚远,要完全记下来基本上是不可能完成的任务。我的建议是,先把基本命令掌握好,然后把高级命令过一遍,只需要达到将来碰到相应问题的时候,可以快速的寻找解决方案即可。学习的过程中有不明白的地方可以随时Google和参考Stackoverflow。