0、导读
本文适合对git有过接触但知其然不知其所以然的小伙伴也适合想要学习git的初学者通过这篇文章能让大家对git有豁然开朗的感觉。在写作过程中我力求通俗易懂深入浅出不堆砌概念。你能够从本文中了解以下知识
Git是什么
Git能够解决哪些问题
Git的实现原理
请注意,本文的阐述逻辑是Git是什么——>Git要解决的根本问题是什么——>git是如何解决这些问题的。
1、Git是什么
Git是一种分布式版本控制系统。
有人要问了什么是“版本控制”Git又为什么被冠以“分布式”的名头呢这两个问题我们一一解答。
版本控制这个说法多少有一点抽象。事实上版本控制这件事儿我们一直在做只是平时不这么称呼。举一个栗子boss让你写一个策划案你先完成了一稿之后又有了一些新的想法但是并不确定新的想法是否能得到boss的认可于是你保存了一个初稿之后在初稿的基础上另存了一个文件做了部分修改完成了一个修改稿。OK这时你的策划案就有了两个版本——初稿和修改稿。如果boss对修改稿不满意你可以很轻易的把初稿拿出来交差。
在这个简单的过程中你已经执行了一个简单的版本控制操作——把文档保存为初稿和修改稿的过程就是版本控制。
学术点说版本控制就是对文件变更过程的管理。说白了版本控制就是要把一个文件或一些文件的各个版本按一定的方式管理起来目的是需要用到某个版本的时候可以随时拿出来。
另一个个问题为什么说Git是“分布式”版本控制系统呢
这里的“分布式”是相对于“集中式”来说的。把数据集中保存在服务器节点所有的客户节点都从服务节点获取数据的版本控制系统叫做集中式版本控制系统比如svn就是典型的集中式版本控制系统。
与之相对Git的数据不止保存在服务器上同时也完整的保存在本地计算机上所以我们称Git为分布式版本控制系统。
Git的这种特性带来许多便利比如你可以在完全离线的情况下使用Git随时随地提交项目更新而且你不必为单点故障过分担心即使服务器宕机或数据损毁也可以用任何一个节点上的数据恢复项目因为每一个开发节点都保存着完整的项目文件镜像。
2、Git能够解决哪些问题
就像上文举的例子一样在未接触版本控制系统之前大多人会通过保存项目或文件的备份来达到版本控制的目的。通常你的文件或文件夹名会设置成“XXX-v1.0”、“XXX-v2.0”等。
这是一种简单的办法但过于简单。这种方式无法详细记录版本附加信息难以应付复杂项目或长期更新的项目缺乏版本控制约定对协作开发无能为力。如果你不慎使用了这种方式那么稍稍过一段时间你就会发现连自己都不知道每个版本间的区别版本控制形同虚设。
Git能够为我们解决版本控制方面的大多数问题利用Git
我们可以为每一次变更提交版本更新并且备注更新的内容
我们可以在项目的各个历史版本之间自如切换
我们可以一目了然的比较出两个版本之间的差异
我们可以从当前的修改中撤销一些操作
我们可以自如的创建分支、合并分支
我们可以和多人协作开发
我们可以采取自由多样的开发模式。
诸如此类数不胜数。然而实现这些功能的基础是对文件变更过程的存储。如果我们能抓住这个根本提纲挈领的学习git会事半功倍。
随着对Git更深入的学习你会发现它会变得越来越简单越来越纯粹。道家有万法归宗的说法用在这里再合适不过。因为Git之所以有如此多炫酷的功能根源只有一个它很好的解决了文件变更过程存储这一个问题。
所以如果问“Git能够解决哪些问题”我们可以简单的回答Git解决了版本控制方面的很多问题但最核心的是它很好的解决了版本状态存储即文件变更过程存储的问题。
3、Git的实现原理
我们说到Git很好的解决了版本状态记录的问题在此基础上实现了版本切换、差异比较、分支管理、分布式协作等等炫酷功能。那么这一节我们就先从最根本的讲起看看Git是如何解决版本状态记录即文件变更过程记录问题的。
我们都有版本记录的经验比如在文档撰写的关键点上保留一个备份或在需要对文件进行修改的时候“另存”一次。这都是很好的习惯也是版本状态记录的一种常用方式。事实上Git采取了差不多的方式。
在我们向Git系统提交一个版本的时候Git会把这个版本完整保存下来。这是不是和“另存”有异曲同工之妙呢不同之处在于存储方式在Git系统中一旦一个版本被提交那么它就会被保存在“Git数据库”中。
3.1 Git数据库
我们提到了“Git数据库”这是什么玩意儿呢为了能够说清楚Git数据库的概念我们暂且引入三个Git指令通过这三个命令我们就能一探git数据库的究竟。
git init 用于创建一个空的git仓库或重置一个已存在的git仓库
git hash-object git底层命令用于向Git数据库中写入数据
git cat-file git底层命令用于查看Git数据库中数据
首先我们用git init新建一个空的git仓库。希望小伙伴们可以跟着我的节奏一起来实际操作一下会加深理解。如果有还没有安装好git工具的同学请自行百度安装我不会讲安装的过程。
我用的是ubuntu系统在terminal下执行
$ git init GitTest Initialized empty Git repository in /home/mp/Workspace/GitTest/.git/ |
这一命令在当前目录下生成了一个新的文件夹-GitTest在GitTest中包含了一个新建的空git仓库。如果你不明白git仓库是什么那么可以简单的理解为存放git数据的一个空间这这个例子中是“/home/mp/Workspace/GitTest/.git”目录。
接下来我们看看git仓库的结构是什么样的。执行
$ cd GitTest $ ls $ find .git .git |
我们发现GitTest目录下除隐藏目录.git之外并没有其他文件或文件夹。
我们通过find .git命令查看新生成的空git仓库的结构会发现其中有一个objects文件夹这就是git数据库的存储位置。
3.1 Git数据库的写入操作
紧接着我们利用git底层命令git hash-object向git数据库中写入一些内容。执行命令
$ echo "version 1" | git hash-object -w --stdin 83baae61804e65cc73a7201a7252750c76066a30 $ find .git/objects/ -type f |
"|"表示这是一条通道命令意思是把“|”前边的命令的输出作为“|”后边命令的输入。git hash-object -w --stdin 的意思是向git数据库中写入一条数据-w,这条数据的内容从标准输入中读取--stdin。
命令执行后会返回个长度为40位的hash值这个hash值是将待存储的数据外加一个头部信息一起做SHA-1校验运算而得的校验和。在git数据库中它有一个名字叫做“键值key”。相应的git数据库其实是一个简单的“键值对key-value”数据库。事实上你向该数据库中插入任意类型的内容它都会返回一个键值。通过返回的键值可以在任意时刻再次检索该内容。
此时我们再次执行find .git/objects/ -type f命令查看objects目录会发现目录中多出了一个文件这个文件存储在以新存入数据对应hash值的前2位命名的文件夹内文件名为hash值的后38位。这就是git数据库的存储方式一个文件对应一条内容就是这么简单直接。
3.2 Git数据库的查询操作
我们可以通过git cat-file这个git底层命令查看数据库中某一键值对应的数据。执行
$ git cat-file -t 83baa blob $ git cat-file -p 83baa version 1 |
其中-t选项用于查看键值对应数据的类型-p选项用于查看键值对应的数据内容83bba为数据键值的简写。
由执行结果可见所查询的键值对应的数据类型为blob数据内容为“version 1”。blob对象我们称之为数据对象这是git数据库能够存储的对象类型之一后面我们还会讲到另外两种对象分别是树tree对象和提交commit对象。
截止到这里你已经掌握了如何向git数据库里存入内容和取出内容。这很简单但是却意义非凡因为对git数据库的操作正是git系统的核心——git的版本控制功能就是基于它的对象数据库实现的。在git数据库里存储着纳入git版本管理的所有文件的所有版本的完整镜像。
git这么简单吗不用怀疑git就是这么简单我们已经准确的抓住了它的根本要义——对象数据库。接下来我们会利用git数据库搭建起git的高楼大厦。
3.3 使用Git跟踪文件变更
我们明白所谓跟踪文件变更只不过是把文件变更过程中的各个状态完整记录下来。
我们模拟一次文件变更的过程看看仅仅利用git的对象数据库能不能实现“跟踪文件变更”的功能。
首先我们执行
$ echo "version 1" > file.txt
$ git hash-object -w file.txt 83baae61804e65cc73a7201a7252750c76066a30 |
我们把文本“version 1”写入file.txt中并利用git hash-object -w
file.txt命令将其保存入数据库中。如果你足够细心会发现返回的hash键值和利用echo "version 1" | git
hash-object -w --stdin写入数据库时是一致的这很正常因为我们写入的内容相同。git
hash-object命令在只指定-w选项的时候会把file.txt文件内容写入数据库。
此时执行
$ find .git/objects -type f .git/objects/83/baae61804e65cc73a7201a7252750c76066a30 |
会发现.git/objects目录中依然只有一个文件。可见git数据库存储文件时只关心文件内容与文件的名字无关。
接下来我们修改file.txt的内容执行
$ echo "version 2" > file.txt $ git hash-object -w file.txt 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a $ find .git/objects -type f .git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a |
我们发现.git/objects下多出了一个文件这是我们新保存进数据库的file.txt。接下来我们执行git cat-file搞清楚这两条数据的内容分别是什么。执行
$git cat-file -p 83baa version 1 $git cat-file -p 1f7a7a version 2 |
我们发现file.txt的变更过程被完整的记录下来了。
当前的file.txt中保存的内容是“version 2”如果我们想把文件恢复到修改为“version 2”之前的状态只需执行
$ cat file.txt $ git cat-file -p 83baa > file.txt $ cat file.txt |
file.txt的内容成功恢复到了修改前的状态变成了“version 1”。这其实就是版本回滚的实质。
OK文件变更状态跟踪的道理就是这么简单。
但做到这一步还远远不算完美至少有以下几方面的问题
第一无法记录文件名的变化
第二无法记录文件夹的变化
第三记忆每一个版本对应的hash值无聊且乏味且不可能
第四无法得知文件的变更时序
第五缺少对每一次版本变化的说明。
问题不少但都是简单的小问题我们一一解决。
3.4 利用树对象tree object解决文件名保存和文件组织问题
Git利用树对象tree object解决文件名保存的问题树对象也能够将多个文件组织在一起。
Git通过树tree对象将数据blob对象组织起来这很类似于一种文件系统——blob对象对应文件内容tree对象对应文件的目录和节点。一个树tree对象包含一条或多条记录每条记录含有一个指向blob对象或tree对象的SHA-1指针以及相应的模式、类型、文件名。
有了树对象我们就可以将文件系统任何时间点的状态保存在git数据库中这是不是很激动人心呢你的一个复杂的项目可能包含成百上千个文件和文件目录有了树对象这一切都不是问题。
创建树对象
通常Git根据某一时刻暂存区所表示的状态创建并记录一个对应的树对象如此重复便可以依次记录一系列的树对象。Git的暂存区是一个文件——.git/index。下面我们通过创建树对象的过程来认识暂存区和树对象。
为了创建一个树对象我们需要通过暂存一些文件来创建一个暂存区。为此我们引入两个命令
git update-index git底层命令用于创建暂存区
git ls-files --stage git底层命令用于查看暂存区内容
git write-tree git底层命令用于将暂存区内容写入一个树对象
OK万事俱备我们将file.txt的第一个版本放入暂存区执行
$ find .git/index find: ‘.git/index’: No such file or directory $ git update-index --add file.txt $ find .git/index .git/index $ cat .git/index DIRC[$;[$;A aNes rRu vjfile.txt݀3%A,I ` $ find .git/objects/ -type f .git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a .git/objects/83/baae61804e65cc73a7201a7252750c76066a30 $ git ls-files --stage 100644 83baae61804e65cc73a7201a7252750c76066a30 0 file.txt $ git write-tree 391a4e90ba882dbc9ea93855103f6b1fa6791cf6 $ find .git/objects/ -type f .git/objects/39/1a4e90ba882dbc9ea93855103f6b1fa6791cf6 .git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a .git/objects/83/baae61804e65cc73a7201a7252750c76066a30 |
分析执行结果
首先我们注意.git/index文件的变化。在添加file.txt到暂存区前index文件并不存在这说明暂存区还没有创建。添加file.txt到暂存区的同时index文件被创建。
其次我们看git数据库的变化。我们发现在执行git update-index 之后git数据库并没有改变依然是只有两条数据。在执行git write-tree之后git数据库中多出了一条新的记录键值为391a4e90ba882dbc9ea93855103f6b1fa6791cf6。
我们执行git cat-file来查看一下多出来的这条记录是什么内容。执行
$ git cat-file -t 391a4e tree $ git cat-file -p 391a4e 100644 blob 83baae61804e65cc73a7201a7252750c76066a30 file.txt |
由执行结果可见git数据库中新增加的记录是一个tree对象该tree对象指向一个blob对象hash键值为
83baae61804e65cc73a7201a7252750c76066a30 |
这一个blob对象是之前我们添加进数据库的。
以上我们添加了一个已经存在在git数据库中的文件到暂存区如果我们新建一个未曾保存到git数据库的文件存入暂存区进而保存为tree对象会有什么不同吗我们试试看。执行
$ echo "new file" > new $ git ls-files --stage |
由执行结果我们可以看到这一次执行update-index之后和上次不同git数据库发生了变化新增加了一条hash键值为“fa49b0”的数据暂存区中也多出了文件new的信息。
这说明两个问题
如果添加git数据库中尚未存储的数据到暂存区则在执行update-index的时候会同时把该数据保存到git数据库。
添加文件进入暂存区的操作是追加操作之前已经加入暂存区的文件依然存在——很多人会有误区认为变更提交之后暂存区就清空了。
此时我们查看新添加的树对象执行
$ git cat-file -p 228e49 100644 blob 83baae61804e65cc73a7201a7252750c76066a30 file.txt 100644 blob fa49b077972391ad58037050f2a75f74e3671e92 new |
此次write-tree写入数据库的是tree对象包含了两个文件。
更进一步我们是否能将一个子文件夹保存到树对象呢尝试一下执行
$ mkdir new_dir $ git update-index --add new_dir error: new_dir: is a directory - add files inside instead fatal: Unable to process path new_dir |
我们发现无法将一个新建的空文件夹添加到暂存区。错误提示告诉我们应该将文件将文件夹中的文件加入到暂存区add files inside instead。
OK接下来我们在新建的文件夹下写入一个文件再尝试将这一文件加入暂存区。执行
$ echo "file in new dir" > new_dir/new $ git write-tree $ git cat-file -p 06564b |
从执行结果可见文件夹new_dir对应一个tree对象。
至此在git数据库中我们可以完整的记录文件的状态、文件夹的状态并且可以把多个文件或文件夹组织在一起记录他们的变更过程。我们离一个完善的版本控制系统似乎已经不远了而这一切实现起来又是如此简单——我们只是通过几个命令操作git数据库就完成了这些功能。
接下来我们只要把数据库中各个版本的时序关系记录下来再把对每一个版本更新的注释记录下来不就完成了一个逻辑简单、功能强大、操作灵活的版本控制系统吗
那么如何记录版本的时序关系如何记录版本的更新注释呢这就要引入另一个git数据对象——提交对象commit object。
3.5 利用提交对象commit object记录版本间的时序关系和版本注释
commit对象能够帮你记录什么时间由什么人因为什么原因提交了一个新的版本这个新的版本的父版本又是谁。
git提供了底层命令commit-tree来创建提交对象commit object我们需要为这个命令指定一个被提交的树对象的hash键值以及该提交对象的父提交对象如果是第一次提交不需要指定父对象。
我们尝试将之前创建的树对象提交为commit 对象执行
$ git write-tree cb0fbcc484a3376b3e70958a05be0299e57ab495 $ git commit-tree cb0fbcc -m "first commit" 7020a97c0e792f340e00e1bb8edcbafcc4dfb60f $ git cat-file 7020a97 tree cb0fbcc484a3376b3e70958a05be0299e57ab495 |
在git commit-tree命令中-m选项用于指定本次提交的注释。
我们可以很清楚的看到一个提交对象包含着所提交版本的树对象hash键值author和commiter以及修改和提交的时间最后是本次提交的注释。
其中committer和author是通过git config命令设置的。
接下来修改某个文件重新创建一个树对象并将这一树对象提交作为项目的第二个提交版本。执行
$ echo "new version" > file.txt $ git update-index file.txt $ git write-tree |
我们可以按照上述步骤再提交第三个版本。
$ echo "another version" > file.txt $ git update-index file.txt $ git write-tree 92867fcc5e0f78c195c43d1de25aa78974fa8103 $ git commit-tree 92867 -p e838c -m "third commit" 491404fa6e6f95eb14683c3c06d10ddc5f8e883f $ git cat-file -p 49140 tree 92867fcc5e0f78c195c43d1de25aa78974fa8103 parent e838c8678ef789df84c2666495663060c90975d7 author john <john@163.com> 1537963274 +0800 committer john <john@163.com> 1537963274 +0800
third commit |
提交完三个版本我们通过git log 查看最近一个提交对象的提交记录
$ git log 49140 commit 491404fa6e6f95eb14683c3c06d10ddc5f8e883f Author: john <john@163.com> Date: Wed Sep 26 20:01:14 2018 +0800 third commit commit e838c8678ef789df84c2666495663060c90975d7 Author: john <john@163.com> Date: Wed Sep 26 19:47:22 2018 +0800 second commit commit 7020a97c0e792f340e00e1bb8edcbafcc4dfb60f Author: john <john@163.com> Date: Wed Sep 26 19:31:18 2018 +0800 first commit |
太神奇了 就在刚才我们围绕git数据库仅凭几个底层数据库操作便完成了一个 Git 提交历史的创建。到此为止我们已经完全掌握了git的内在逻辑。
接触过git的小伙伴会发现以上我们用到的这些指令在使用git过程中是用不到的。这是为什么呢因为git对以上这些指令进行了封装给用户提供了更便捷的操作命令如addcommit等。
每次我们运行 git add
和 git commit
命令时 Git 所做的实质工作是将被改写的文件保存为数据对象更新暂存区记录树对象最后创建一个指明了顶层树对象和父提交的提交对象。 这三种主要的 Git 对象——数据对象、树对象、提交对象——最初均以单独文件的形式保存在 .git/objects
目录下。
然而小问题依然存在截止目前为止我们对版本和数据对象的操作都是基于hash键值的这些毫无直观含义的字符串让人很头疼不会有人愿意一直急着最新提交对应的hash键值的。git不会允许这样的问题存在的它通过引入“引用references”来解决这一问题。
3.6 Git的引用
Git的引用references保存在.git/refs目录下。git的引用类似于一个指针它指向的是某一个hash键值。
创建一个引用实在再简单不过。我们只需把一个git对象的hash键值保存在以引用的名字命名的文件中即可。
执行
$ echo "491404fa6e6f95eb14683c3c06d10ddc5f8e883f" > .git/refs/heads/master $ cat .git/refs/heads/master 491404fa6e6f95eb14683c3c06d10ddc5f8e883f |
就这样我们便成功的建立了一个指向最新一个提交的引用引用名为master
在此之前我们查看提交记录需要执行 git log 491404现在只需执行git log master。
$ git log 491404 commit 491404fa6e6f95eb14683c3c06d10ddc5f8e883f (HEAD -> master) Author: john <john@163.com> Date: Wed Sep 26 20:01:14 2018 +0800
third commit
commit e838c8678ef789df84c2666495663060c90975d7
second commit
commit 7020a97c0e792f340e00e1bb8edcbafcc4dfb60f
first commit
third commit
commit e838c8678ef789df84c2666495663060c90975d7
second commit
commit 7020a97c0e792f340e00e1bb8edcbafcc4dfb60f
first commit |
结果完全相同。
Git并不提倡直接编辑引用文件它提供了一个底层命令update-ref来创建或修改引用文件。
echo "491404fa6e6f95eb14683c3c06d10ddc5f8e883f" > .git/refs/heads/master 命令可以简单的写作
$ git update-ref refs/heads/master 49140 |
这基本就是 Git 分支的本质一个指向某一系列提交之首的指针或引用。
4. Git基本原理总结
Git的核心是它的对象数据库其中保存着git的对象其中最重要的是blob、tree和commit对象blob对象实现了对文件内容的记录tree对象实现了对文件名、文件目录结构的记录commit对象实现了对版本提交时间、版本作者、版本序列、版本说明等附加信息的记录。这三类对象完美实现了git的基础功能对版本状态的记录。
Git引用是指向git对象hash键值的类似指针的文件。通过Git引用我们可以更加方便的定位到某一版本的提交。Git分支、tags等功能都是基于Git引用实现的。