Git和SVN是我们最常用的版本控制系(Version Control System, VCS),当然,除了这二者之外还有许多其他的VCS,例如早期的CVS等。顾名思义,版本控制系统主要就是控制、协调各个版本的文档内容的一致性,这些文档包括但不限于代码文件、图片文件等等。早期SVN占据了绝大部分市场,而后来随着Git的出现,越来越多的人选择将它作为版本控制工具,社区也越来越强大。相较于SVN,最核心的区别是Git是分布式的VCS,简而言之,每一个你pull下来的Git仓库都是主仓库的一个分布式版本,仓库的内容完全一样,而SVN则不然,它需要一个中央版本库来进行集中控制。采用分布式模式的好处便是你不再依赖于网络,当有更改需要提交的时候而你又无法连接网络时,你只需要把更改提交到本地的Git仓库,最后有网络的时候再把本地仓库和远程的主仓库进行同步即可。当然,分布式和非分布式各有各的优缺点,但是目前来看,分布式的Git正逐渐被越来越多的人所接受并推广。本文主要对Git的基本原理和常用命令进行简介,试图从底层来说明Git是如何工作的,从而帮助大家理解上层命令在执行的时候背后所产生的动作和变化。原理部分的内容可以参考Pro Git做进一步的了解,而常用的命令可以参考其他的资料。本文的总结根据自己的理解进行描述,如果错误,请不吝赐教。
Git的基本原理
本质上,Git是一套内容寻址(content-addressable)文件系统,而和我们直接接触的Git界面,只不过是封装在其之上的一个应用层。这个关系颇有点类似于计算机网络中应用层和下属层的关系。在Git中,那些和应用层相关的命令(也就是我们最常用的命令,如git commit、 git push等),我们称之为porcelain命令(瓷器之意,意为成品、高级命令);而和底层相关的命令(几乎不会在日常中使用,如git hash-object、git update-index等),则称之为plumbing命令(管道之意,是连接git应用界面和git底层实现的一个管道,类似于shell,底层命令)。要了解Git的底层原理,就需要了解Git是如何利用底层命令来实现高层命令的。在此之前,让我们先来看一下Git的目录结构,和各个文件在Git中的作用。
Git的目录结构
在操作系统中,我们的仓库就是一个文件夹。但是为什么这些文件夹就是Git仓库呢?这是因为Git在初始化的时候会生成一个.git的文件夹,而Git进行版本控制所需要的文件,则都放在这个文件夹中。在桌面上新建一个目录,然后利用命令行在该目录下运行git init命令即可完成git仓库的初始化。如果这个时候你看不到.git目录,这是因为你的操作系统自动隐藏了该文件夹,需要在系统设置中设置隐藏文件可见。进入.git目录,便可以看到其中有很多的文件和文件夹,这每一个文件都有各自的作用,下面结合图1来进行说明。
图1 .git目录结构示意图
在上图中,第一排的几个文件和文件夹是Git的核心,而第二排的则是一些不需要特别关注的。核心文件包括:config文件、objects文件夹、HEAD文件、index文件以及refs文件夹。下面依次对其进行说明。
- config文件:该文件主要记录针对该项目的一些配置信息,例如是否以bare方式初始化、remote的信息等,通过git remote add命令增加的远程分支的信息就保存在这里;
- objects文件夹:该文件夹主要包含git对象。关于什么是git对象,将会在下一节进行详细介绍。Git中的文件和一些操作都会以git对象来保存,git对象分为BLOB、tree和commit三种类型,例如git commit便是git中的commit对象,而各个版本之间是通过版本树来组织的,比如当前的HEAD会指向某个commit对象,而该commit对象又会指向几个BLOB对象或者tree对象。objects文件夹中会包含很多的子文件夹,其中Git对象保存在以其sha-1值的前两位为子文件夹、后38位位文件名的文件中;除此以外,Git为了节省存储对象所占用的磁盘空间,会定期对Git对象进行压缩和打包,其中pack文件夹用于存储打包压缩的对象,而info文件夹用于从打包的文件中查找git对象;
- HEAD文件:该文件指明了git branch(即当前分支)的结果,比如当前分支是master,则该文件就会指向master,但是并不是存储一个master字符串,而是分支在refs中的表示,例如ref: refs/heads/master。
- index文件:该文件保存了暂存区域的信息。该文件某种程度就是缓冲区(staging area),内容包括它指向的文件的时间戳、文件名、sha1值等;
- Refs文件夹:该文件夹存储指向数据(分支)的提交对象的指针。其中heads文件夹存储本地每一个分支最近一次commit的sha-1值(也就是commit对象的sha-1值),每个分支一个文件;remotes文件夹则记录你最后一次和每一个远程仓库的通信,Git会把你最后一次推送到这个remote的每个分支的值都记录在这个文件夹中;tag文件夹则是分支的别名,这里不需要对其有过多的了解;
除此以外,.git目录下还有很多其他的文件和文件夹,这些文件和文件夹会额外支撑一些其他的功能,但是不是Git的核心部分,因此稍作了解即可。hooks主要定义了客户端或服务端钩子脚本,这些脚本主要用于在特定的命令和操作之前或者之后进行特定的处理,比如:当你把本地仓库push到服务器的远程仓库时,可以在服务器仓库的hooks文件夹下定义post_update脚本,在该脚本中可以通过脚本代码将最新的代码部署到服务器的web服务器上,从而将版本控制和代码发布无缝连接起来;description文件仅供GitWeb程序使用,这里不需要过多的关心;logs则记录了本地仓库和远程仓库的每一个分支的提交记录,即所有的commit对象(包括时间、作者等信息)都会被记录在这个文件夹中,因此这个文件夹中的内容是我们查看最频繁的,不管是Git log命令还是tortoiseGit的show log,都需要从该文件夹中获取提交日志;info文件夹保存了一份不希望在.gitignore 文件中管理的忽略模式的全局可执行文件,基本也用不上;COMMIT_EDITMSG文件则记录了最后一次提交时的注释信息。从以上的描述中我们可以发现,.git文件夹中包含了众多功能不一的文件夹和文件,这些文件夹和文件是描述Git仓库所必不可少的信息,不可以随意更改或删除;尤其需要注意的是,.git文件夹随着项目的演进,可能会变得越来越大,因为任何文件的任何一个变动,都需要Git在objects文件夹下将其重新存储为一个新的对象文件,因此如果一个文件非常大,那么你提交几次改动就会造成.git文件夹容量成倍增长。因此,.git文件夹更像是一本书,每一个版本的每一个变动都存储在这本书中,而且这本书还有一个目录,指明了不同的版本的变动内容存储在这本书的哪一页上,这就是Git的最基本的原理。
从底层命令理解Git
上节中我们讲到,Git分为porcelain命令和plumbing命令,而porcelain命令是基于plumbing来实现的。为了进一步的理解Git的底层原理,我们将在这一节中详细的探讨Git对象的存储格式以及plumbing命令。如果把Git比作Linux操作系统,那plumbing命令就有点类似于shell命令,而上层的procelain命令便是利用shell命令编写的一系列的系统功能或工具,如你自定义的自动化运维工具等。在接下来的介绍中,我们将试着如何利用plumbing命令,而不是porcelain命令,来完成Git的暂存和提交工作,并利用log查看提交记录。首先,我们从Git的对象介绍开始。
Git对象
在之前我们提到过,Git是一套内容寻址(content-addressable)文件系统,那么Git是怎么进行寻址呢?其实,寻址无非就是查找,而Git采用HashTable的方式进行查找,也就是说,Git只是通过简单的存储键值对(key-value pair)的方式来实现内容寻址的,而key就是文件(头+内容)的哈希值(采用sha-1的方式,40位),value就是经过压缩后的文件内容。因此,在接下来的实践中,我们会经常通过40位的hash值来进行plumbing操作,几乎每一个plumbing命令都需要通过key来指定所要操作的对象。
Git对象的类型包括:BLOB、tree对象、commit对象。BLOB对象可以存储几乎所有的文件类型,全称为binary large object,顾名思义,就是大的二进制表示的对象,这种对象类型和数据库中的BLOB类型(经常用来在数据库中存储图片、视频等)是一样的,当做一种数据类型即可;tree对象是用来组织BLOB对象的一种数据类型,你完全可以把它想象成二叉树中的树节点,只不过Git中的树不是二叉树,而是"多叉树";commit对象表示每一次的提交操作,由tree对象衍生,每一个commit对象表示一次提交,在创建的过程中可以指定该commit对象的父节点,这样所有的commit操作便可以连接在一起,而这些commit对象便组成了提交树,branch只不过是这个树中的某一个子树罢了。如果你能理解commit树,那Git几乎就已经理解了一半了。
Git对象的存储方式也很简单,基本可以用如下表达式来表示:
Key = sha1(file_header + file_content) Value = zlib(file_content) |
简单来说,Git 将文件头与原始数据内容拼接起来,并计算拼接后的新内容的 40位的sha-1校验和,将该校验和的前2位作为object目录中的子目录的名称,后38位作为子目录中的文件名;然后,Git 用zlib的方式对数据内容进行压缩,最后将用 zlib 压缩后的内容写入磁盘。文件头的格式为 "blob #{content.length}\0",例如"blob 16\000",这种文件头格式也是经常采用的格式。对于tree对象和commit对象,文件头的格式都是一样的,但是其文件数据却是有固定格式的,鉴于本次只是Git原理的基本介绍,这里不再详细描述,有兴趣的可以去Git的官网查找相关文档进行了解;其实也可以自己按照理解构思一下,如果让你来设计这种格式,应该如何设计:tree对象类似于树中节点的定义,在tree对象中要包含对连接的BLOB对象的引用,而commit对象与tree对象类似,要包含提交的tree对象的引用,想到这里,我觉得文档的阅读大概也就可以省去了。
对象暂存区
在procelain命令中,为了将修改的文件加入暂存区(也叫索引库,将修改的文件key-value化,.git根目录下的index文件记录该暂存区中的文件索引),我们会使用git add filename命令。那么在git add这个命令的背后,Git是如何使用plumbing命令来完成文件的索引操作呢?其实,git add命令对应着两个基本的plumbing命令:
git hash-object #获取指定文件的key,如果带上-w选项,则会将该对象的value进行存储 |
git update-index #将指定的object加入索引库,需要带上—add选项 |
因此,git add命令在plumbing命令中其实是分成了两步:首先,通过hash-object命令将需要暂存的文件进行key-value化转换成Git对象,并进行存储,拿到这些文件的key;然后,通过update-index命令将这些对象加入到索引库进行暂存,这样便完成了Git文件的暂存操作。如果要根据Git对象的key来查看文件的信息,还需要涉及下面的一个plumbing命令:
git cat-file –p/-t key #获取指定key的对象信息,-p打印详细信息,-t打印对象的类型 |
利用该命令可以查看已经key-value化的Git对象的详细信息。
接下来,我们利用plumbing命令来进行git add的实践。首先,新建一个Git仓库,通过在新建的文件夹中利用git init命令来初始化,这里不再详述,如下图所示:
初始化之后,会在当前目录下生成.git目录,进入该目录,就会发现我们上述的目录结构。然后,我们新建一个version.txt文件并在文件中写入"version 1"字符串,这是version.txt的第一个版本,然后利用git hash-object –w命令将该文件转换为Git的对象并存储,如下图:
这里hash-objec命令会返回该Git对象的key值,这时到.git目录的objects目录下会发现,多了一个6c子目录,该目录中的文件名称为58b76a52188643965f3a6704166e8e0424b7fe,也就是该key值的后38位。记下该key值,因为我们要根据该key值将该对象加入索引库。接着,我们利用update-index命令进行索引化操作,如下图:
注意,这里一定要带上—add选项,而—cacheinfo选项则指出该文件的文件类型,100644表示普通文件,与之相关的还有可执行文件等等;并且,除了指定key值,还需要指定文件名,表明要把哪个文件的哪个版本加入索引库。该命令执行完成后,可以发现.git目录下多了index文件,并且在以后每次update-index命令执行之后,该index文件的内容都会发生变化。至此,git add的主要过程也便完成了。
创建树节点
在Git中,所有的内容以tree或者BLOB对象进行存储,如果把Git比作UNIX的文件系统,则tree对象对应于UNIX文件系统中的目录,而BLOB对象则对应于inodes或文件内容。在Git对象小节中,我们大致猜想了tree对象的存储格式。其实,一个单独的tree对象包含一条或多条tree记录,每一条记录含有一个指向BLOB对象或子tree对象的sha-1指针(也就是一个40位的key值),并附有该对象的权限模式 、类型和文件名信息,因此,我们的猜想也是八九不离十的。为什么要创建tree对象呢?我们都知道,在Git中,我们add完已修改的文件之后,一般就直接commit暂存区中的内容到本地仓库了,似乎并没有tree这个概念。其实,创建tree对象只是add和commit中间的一个缓冲步骤,因为commit对象要根据tree对象来创建。那么如何创建tree对象呢?只需要如下命令即可:
git write-tree #根据索引库中的信息创建tree对象 |
该命令返回所创建的tree对象的key值,通过git cat-file可以查看该对象的详细信息。创建过程如下图:
从图中可以看出,cat-file –t显示该对象的类型为tree,表明该tree对象创建成功了,至此,树节点便创建完成了。
Commit对象
在Git中,每一次commit都对应一个commit对象,而一个commit对象对应一个tree对象。为了创建commit对象,需要使用如下命令:
git commit-tree key –p key2 #根据tree对象创建commit对象,-p表示前继commit对象 |
该方法有点类似于数据结构中树的增加节点操作:都是向父节点中增加子节点。其中,-p选项指明了前继commit对象的key值,也就是父节点的key值,这样,这两个commit节点便连接在了一起,而不断的连接便构成了一棵树,也就是我们接下来要讲的提交树。Commit对象的创建过程如下所示:
在该命令中,我们只需要指定key的前六位即可,由于这是第一次提交,因此不需要带上-p选项来指明父节点。通过cat-file命令可以看到,commit对象已创建成功,该commit对象中包含了与之关联的tree对象的key值,以及author和committer的信息。如果要查看完整的提交记录,可以通过git log –stat key命令,该命令会打印指定commit对象之前的所有提交记录。至此,commit对象已经创建完成,而我们也利用plumbing命令,完整的实现了Git的add和commit操作,Cool。到目前为止,所创建的所有对象的关系如下图所示:
这里
提交树Commit Tree
Commit tree
其他概念
其他概念
Git的常用命令
地方
Git log
Fdas
Git fork
放大
Git rebase
放大