之前做版本管理,我使用最多的是SVN,而且也只是在用一些最常用的操作。最近公司里很多项目都开始上Git,借这个机会,我计划好好学习一下Git的操作和原理,以及蕴含在其中的设计思想。同事推荐了一本《Pro Git》,读起来感觉很好,在这里分享下阅读时的思考。此书的在线阅读地址:http://iissnan.com/progit/
这一章介绍了Git的相关历史和基本特点,以及安装配置方法。这里提到的Git的特点包括“直接记录快照,而非差异比较”、“近乎所有操作都是本地执行”、“时刻保持数据完整性”、“多数操作仅添加数据”、“文件的三种状态”,除了最后一点我会放在下一章里梳理,下面会对其中一部分进行一些思考的分享。
Git 和其他版本控制系统的主要差别在于,Git 只关心文件数据的整体是否发生变化,而大多数其他系统则只关心文件内容的具体差异。
这个策略要求Git记录每个版本的完整文件。如果需要对比同一个文件的两个连续版本间差异,Git会直接比较两个文件,而其他系统可以直接把保存的具体差异取出来;但是如果比较间隔版本的文件,后者需要将差异全部合并,才能显示。这意味着版本的间隔越多,基于差异的系统在比较差异所需要的计算量会越大,而Git完全不会受到这个影响。
可以把这个策略看作是空间换时间的实践。现在单位存储空间的费用越来越低,TB级硬盘也已沦为白菜价,即使是开发人员使用的百兆级SSD也已经普及,额外的空间消耗完全可以不做考虑。
在保存到 Git 之前,所有数据都要进行内容的校验和(checksum)计算,并将此结果作为数据的唯一标识和索引。换句话说,不可能在你修改了文件或目录之后,Git 一无所知。这项特性作为 Git 的设计哲学,建在整体架构的最底层。所以如果文件在传输时变得不完整,或者磁盘损坏导致文件数据缺失,Git 都能立即察觉。
Git 使用 SHA-1 算法计算数据的校验和,通过对文件的内容或目录的结构计算出一个 SHA-1 哈希值,作为指纹字符串。该字串由 40 个十六进制字符(0-9 及 a-f)组成,看起来就像是:
24b9da6552252987aa493b52f8696cd6d3b00373
Git 的工作完全依赖于这类指纹字串,所以你会经常看到这样的哈希值。实际上,所有保存在 Git 数据库中的东西都是 用此哈希值来作索引的,而不是靠文件名 。
使用SHA-1产生的hash值而不是文件名做索引的好处是,hash值的长度固定,并且随机性很好,符合哈希充分散列的要求。SHA-1本身就是一种常用的hash函数,其应用不在这里重述。前一段时间Google宣布“将在Chrome浏览器中逐渐降低SHA-1证书的安全指示”,但它这样做的原因是出于安全考虑,并不意味着Git使用SHA-1做hash函数不合适,有兴趣的读者可以看看相关的分析,如: 深度:为什么Google急着杀死加密算法SHA-1 。
文件名做索引有什么坏处呢?长度不固定并不是主要的问题。以用maven管理的代码为例,如果依赖比较复杂,那么各个package中都有各自的pom.xml,它们的文件名是完全一样的,会导致严重的hash碰撞。
这章介绍了最基本的 Git 本地操作:创建和克隆仓库,做出修改,暂存并提交这些修改,以及查看所有历史修改记录。这些操作的命令不再一一列出,来看看第一章提到但没有详细讲述的文件状态。
梳理一下文件各个状态的转换过程和逻辑,可以画出下面的图示。在这张图中,常用的本地的文件操作命令以及将会导致的状态变更就很清楚了:
除了文件状态,简单说下Git里标签的意义。众所周知,SVN里每个版本都是有版本号的,从1开始,每次提交都会升高。而在Git中,每次提交只会返回一个SHA-1 校验和其他的信息,是没有版本号的。
发布时,如何指定Git上的代码版本?这时就可以用tag来做标记了。tag相当于为一个特定的版本增加的标记,可以替代SVN里版本号的功能,而且更强大。
如果要理解Git,要理解Git的分支;如果要理解Git的分支,首先要理解Git中的四个基本的对象模型:blob、tree、commit、tag。这部分原书写的比较简单,具体可以参考《Git Community Book》第一章。幸运的是,该书也有网络版,这一部分内容的地址是:http://gitbook.liuhui998.com/1_2.html。简单地说,这四种对象分别对应于:
分支是把commit对象组织成了链表的形式,不同的分支指向了对应的commit对象,每次在分支上提交,都会在链表表头上插入新的对象,如下图上的master和testing两个分支,图中的绿色方框代表一个commit对象。此时可以通过控制HEAD指针所在位置来指明使用了哪个分支。
简单回忆下链表的相关操作可以发现,只要保存各个分支对应的表头,我们可以很容易的通过给HEAD赋值在各个分支之间切换。同时对于每次提交,链表插入的操作也很简单。
在理解了Git版本管理的链表式的实现方式之后,只要具有基本的算法知识,其他操作原理的理解会变得非常迅速。以下各图来自于《Pro Git》。
1.从master拉新分支iss53,只需新增该分支的指针,未做修改后的提交时,iss53指向master。提交新内容时,创建对应commit对象,iss53指针前移。
2.当分支hotfix的祖先节点中包括master分支,将hotfix分支merge回master分支,只需要把master的指针移动hotfix上,没有任何文件处理工作,因而称之为Fast forward。
3.当分支iss53合并回master分支,但是master不是iss53的祖先时,先计算二者的最近一个的公共祖先,把它和这两个分支的commit进行合并计算,创建新的commit对象。这个对象有两个祖先。如果合并时遇到冲突,不会提交,而是等人工处理完冲突并git add后才能进行提交。
如何寻找交叉链表的第一个公共节点?这是一个常见算法问题,可以参考旧作 《编程之美》3.6判断链表是否相交之扩展:链表找环方法证明 的扩展问题2。
4.(个人推测)查看某个分支是否已合并到master分支:比较两个分支指针是否指向同一个对象。
5.(个人推测)删除已合并master的分支:直接删除该分支的指针;删除未合并的分支(git branch -d XXX):删除该分支不在master上所有commit对象及相关的对象、删除分支指针。
merge是直接将两个分支合并到一起:创建一个合并后的commit节点,祖先有两个,是被合并的两个分支A和分支B,节点内容是三方(分支A、分支B、分支A和B的共有最近祖先)合并的结果。原有的链表上的节点保留,分支上的提交历史没有发生改变。如下图(来自《Pro Git》)所示:
rebase则是将一个分支A中的内容产生的补丁在另一个分支B上重新打一遍,打完之后,分支A的节点变成了分支B的后继。rebase完成后,分支A的特有节点发生了变动。如下图(来自《Pro Git》)所示,C3和C3`是不同的节点:
实际上,merge和rebase产生的节点的内容上是一样的,发生冲突时仍需要人工解决,不同点只是提交的历史节点。rebase更适用于未公开提交(可以理解为push到远程仓库)的对象,清理提交历史;如果对已公开的提交对象rebase,并且已经有人对这些已提交对象开展了后续开发,会使得提交历史非常混乱。详细的例子可以查看原书“分支的衍合”一节。
在“使用Git调试”一节,提到了git bisect进行各个commit的二分查找。众所周知,单链表本身是不支持二分查找的, 推测 Git可能使用了以下两种方式支持:
(1)将起始和结束的两个commit中间所有节点的指针保存到一个临时数组中,二分查找基于这个临时数组进行;
(2)git使用的了类似于跳表的链表。跳表可参考http://www.cnblogs.com/liuhao/archive/2012/07/26/2610218.html。
进一步地推测,在进行二分查找时,对commit进行修改,可能会导致查询错误。
在第一次阅读这一章时,我从第二节开始就有点晕头转向,不知道究竟行文的思路是什么。第二次阅读时才有点眉目,并发觉第一次没看懂的原因是,原文很多地方只是描述底层命令执行后发生的现象,并没有完整地告诉读者这个命令执行的结果。网上很多对git的介绍文章偏实用主义,对这些底层命令并没有花费多少笔墨。好在git自身的文档很完善,git -help <command>对底层命令也有效,可以自行查看。不过方便起见,这里会简单介绍下这些底层命令。以下介绍底层命令时,实际用法为git XXX,如git hash-object,简记为hash-object。
当然,这里的介绍不是文档的翻译,其中也加入了一些个人的理解,因此,各个命令的介绍可能有少量的连续性。
另外一个有趣的事实:Git高层命令是可以自动补全的,而底层命令不行。
计算一个文件(可以通过--stdin指定为从标准输入读取)的对象ID,这个对象ID实际上是git这个 内容寻址文件系统 的K-V关系的键值。可以使用-w选项将该对象添加到git的文件对象库,而不仅仅是把对象的键值显示在屏幕上。
显示git对象的内容或类型,需要指定对象ID。-p用于输出格式化内容。你会发现,通过hash-object生成的文件直接打开是乱码,想要查看原始内容,必须用git cat-file。可以推测,git对象中不仅保存了文件内容,还应该保存了结构信息等,并有被压缩的可能。这节的结尾便证实了这点:先写文件头(包括文件类型和内容长度)、内容正文,再计算SHA-1校验和(作为文件路径和文件名,不参与文件本身的保存),最后进行压缩。
如果对一个tree对象使用cat-file -p,可以看到这个tree对象包括了其他tree或blob对象的引用(同样是对象ID)。
为文件创建或更新index。这样做会导致文件被放入暂存区域(回想下git中文件的staged状态)。运行这个命令之后,往往接下来要运行git write-tree。对同一个文件重复运行也没有任何提示。
为当前的index(注意:此时暂存区可能有多个文件)创建一个tree对象。把update-index和write-tree分开的目的,我认为是Git为了获得更细粒度的控制能力。
将一个的tree对象(可以以--prefix指定其对应的目录名,这个目录此时还不存在)读入index。通过这个命令以及update-index、write-tree,我们就可以任意地 装配 出任何目录-文件的结构了。注意我这里使用的是“装配”而非“组装”,是因为这三个命令是无法进行目录结构的拆分的。
指定一个tree对象,以此创建一个commit对象。如果对一个commit对象再次运行该命令,可以git log看到完整的提交历史,也即这两个commit对象。
安全地更新一个用对象ID表示的文件的引用。其结果和git branch指定某个分支(对应于update-ref的引用)为某个commit(对应于update-ref的对象ID)一样。
轻量级tag对象是可以通过update-ref进行创建的。
给一个标记(如最常见的HEAD)指定一个引用。不指定引用则读取这个标记当前的引用。
清理文件。实际上是将文件进行打包压缩。
查看通过gc进行的已打包的git对象。
阅读的时候遇到了两处翻译错误,我已提交了pull request:
1.第9-2节“ -stdin
指定从标准输入设备 (stdin) 来读取内容,若不指定这个参数则需指定一个要存储的文件的路径。”应为“要读取的文件的路径”。
2.第9-4节 “然后可以用git cat-file命令...”,下面用的是du命令。实际原文中在这里没有提到“git cat-file”。