-
Notifications
You must be signed in to change notification settings - Fork 0
Home
6.1 Git概述
Git能够让不同的协作者通过一个点对点的仓库网络对数据内容(通常是代码,当然不仅限于代码)进行维护。它支持分布式的工作流程,能够让数据内容随时形成分支,并且最终可以合并。
本章将阐述Git的内部实现是如何提供以上功能的,以及它和其他版本控制系统(VCS)的区别。
6.2 Git起源
为了更好地理解Git的设计思想,了解一下Git项目的发源地——Linux内核开发社区——当时所面临的情况是很有益的。
Linux内核开发与同时期的其他商业软件项目有很大不同,因为它的开发者众多,且每个开发者的参与程度和对Linux内核代码的理解有很大差异。多年以来,内核代码一直都是以Tar压缩文件以及补丁的形式维护的,而当时的核心开发团队一直在寻找一个能够满足他们各方面需求的版本控制系统。
Git就是在这样的背景下于2005年作为一款开源软件诞生的。当时,Linux内核代码通过两种版本控制系统进行维护,BitKeeper和CVS,分别由两组核心开发团队开发。BitKeeper相较于当时颇为流行的CVS,提供了一种不同的历史展示方式。
当BitKeeper的所有者BitMover决定收回Linux内核开发人员的使用许可时,Linux Torvalds紧急开启了一个项目,也就是后来的Git。一开始,他通过编写一组Shell脚本来帮助他将邮件中的补丁按顺序应用到代码中。这组原始脚本能够在代码合并过程中迅速中断,让维护者能够进行人工干预,修改代码,然后继续合并。
从项目开始之初,Torvalds就为Git制定了一个目标——要和CVS的做法完全相反——同时还包含了以下三条设计目标:
支持分布式的协作流程,类似BitKeeper 预防代码错乱 高性能 这些设计目标都被实现了,我会在下文中通过解析Git的各种做法来阐述,包括在内容管理中使用有向无环图(DAG),头指针引用,对象模型,远程协议,以及Git如何追踪树的合并。
虽然Git设计之初受到了很多BitKeeper的影响,但是两者还是有根本上的区别的,如Git提供了更多分布式和本地开发流程,这点是BitKeeper做不到的。Monotone,2003年启动的一个开源分布式版本控制系统,也对Git的早期开发产生了影响。
分布式版本控制系统在提供更灵活的工作流程的同时,往往会使它的简易程度降低。分布式模型的独特优点有:
能够线下进行增量提交 开发者可以决定自己的代码何时能够开放出来 能够线下浏览历史 可以将工作成果发布到不同的仓库,以不同的分支、不同的提交粒度展现出来 在Git项目的开发期间,诞生了其他三个开源分布式版本控制系统(其中Mercurial可以参见《开源软件架构》的第一卷)。这些分布式版本控制系统(dVCS)都提供了非常灵活的工作流程,这是先前的集中式版本控制系统做不到的。注意:Subversion有一款插件名为SVK,由不同的开发者维护,提供了服务器之间的同步功能。
目前流行的dVCS包括Bazaar, Darcs, Fossil, Git, Mercurial, 以及Veracity。
6.3 版本控制系统的设计
现在让我们回过头来看看Git之外的其他版本控制系统是如何设计的。通过比较他们和Git之间的区别,可以帮助我们去理解Git在架构设计中的选择。
版本控制系统通常有三项核心功能(需求):
保存内容 记录变更历史(包括具体的合并信息) 向协作者分发内容和变更历史 注意:第三项并不是所有版本控制系统的核心功能。
保存内容
在VCS中保存内容,最普遍的做法是保存增量的修改,或使用有向无环图进行内容表示(DAG)。
增量修改可以反映出两个版本之间的内容差异,以及一些额外的元数据。有向无环图进行内容表示的方法则是指将对象形成一个层级结构,这个层级反映了文件系统的树状结构,会作为某一次提交的快照保存下来(树状结构中未发生变化的对象是可以重用的)。Git使用有向无环图来保存内容,有向无环图中使用的不同对象类型会在本文的"对象数据库"一节中有所描述。
提交和合并的历史
在保存历史、记录变化方面,大部分VCS使用以下方式之一:
线性历史 有向无环图 Git使用的还是有向无环图,这次则是用来保存历史。每次提交包含了它父节点的元信息——Git中的一次提交可以拥有0个或多个父节点(理论上没有个数限制)。例如,Git仓库的第一次提交就没有父节点,而一次三头合并则有三个父节点。
Git和SVN线性历史的另一个重要区别是Git可以直接进行分支的创建,并记录下大部分合并历史。
图6.1:Git中有向无环图示例
图6.1:Git中有向无环图示例
通过采用有向无环图保存内容,Git能够提供完整的分支功能。一个文件的历史会通过它所处的目录结构位置和根节点关联起来,并最终和一个提交节点关联。这个提交节点又会有一个或多个父节点。这种组织方式提供了以下两个特性,让我们能够更好地在Git浏览文件历史和内容:
当内容节点(文件或目录)在有向无环图中有相同的标识(Git中以SHA码表示),即使它们处于不同的提交节点,也能保证它们的内容是一致的,从而使得Git在差异比对时更为高效。 在对两个分支进行合并时,实质上是在对两个有向无环图节点中的内容进行合并。有向无环图能够让Git更为高效地判断出他们共同的父节点。 内容分发
版本控制系统在向协作者分发内容时通常有以下三种做法:
仅限本地:某些版本控制系统没有上文提到的第三项需求。 中央服务器:版本库的所有改动都必须在一个中央版本库中进行,也只有这个版本库会记录历史。 分布式模型:虽然分布式模型中也会有一个中央仓库供协作者"推送"自己的改动,但协作者可以在本地进行提交,并稍后再推送到远程。 为了展示以上设计模式的优点和不足,我们设想这样一个应用场景:一个SVN仓库和一个Git仓库,有着相同的内容(即Git默认分支的头指针指向的内容和SVN仓库最新的trunk分支的最新版本一致)。一个名叫Alex的开发者在本地检出了一份SVN代码,以及克隆了一个Git版本库。
假设Alex在本地SVN中对一个1M大小的文件进行了修改,并进行了提交。提交后本地更新了最近改变和本地元信息,远程服务器则是将文件的差异记录了下来。
Git下则有所不同。Alex对文件的变动首先会在本地进行记录,然后再"推送"到远程的公共仓库,这样文件的改动就能被其他开发者看到了。文件内容的变动记录在不同的版本库之中的表示方式是完全一致的。除了本地提交之外,Git会为变动后的文件创建一个对象来保存它(包括其完整的内容),然后用新的标识符逐层为该文件的父目录创建对象,直至仓库根目录。接下来Git会创建一个有向无环图,从刚才新创建的根目录节点开始,指向各个二进制单元(期间会重用那些内容没有改变的二进制单元),并使用新创建的二进制单元去替代那些变动的部分(一个二进制单元通常用来表示一个文件)。
到此为止,本次提交还是只保存在Alex克隆下来的本地仓库中。当Alex将这个提交推送到远程仓库后,远程仓库会验证这次提交是否能应用到当前分支中,然后这些对象将会按照原样保存下来,如同在本地仓库中创建的一样。
在Git中会有很多可变动的部分,有些对用户是透明的,有些则需要用户显示地指定这些内容是否需要分享出来,或是只在本地保存。虽然增加了复杂性,但也提供给团队开发者更大的自由度,得以更好地控制工作流程和发布内容,这在"Git起源"一节中已经有所阐述。
在SVN中,开发者不会忘记将变动内容提交至远程仓库。从效率上讲,SVN仅保存文件变动的方式会比Git保存文件每个版本的完整内容要来得高效,但是之后我们会讲述Git其实已经通过某种方式对此进行了优化。
6.4 工具包
如今,Git已然形成一个生态系统,在各种操作系统上(包括Windows)都开发出了大量命令行和图界面工具,而他们大部分都是构建在Git核心工具包之上的。
由于Git是Linus发起和开发的,它又立足于Linux社区,因此Git工具包的设计理念和传统的Unix命令行工具相仿。
Git工具包分为两个部分:底层命令和上层命令。底层命令提供了基本的内容追踪手段,以及直接操纵有向无环图。上层命令则是用户主要接触的命令,用以维护仓库,以及在多个仓库间进行协作。
虽然Git工具包提供了足够多的命令来操纵仓库,但是开发者们还是抱怨Git没有提供类库以供调用。Git命令最终会执行die()方法,使得GUI和Web界面在使用它时必须启动一个新的进程,效率较低。
不过这一问题已经得到处理,将会在本文的"当前进展和未来规划"一节加以阐述。
6.5 版本库、暂存区、工作区
让我们开始深入研究一下Git吧,了解其中几个关键概念。
首先让我们在本地创建一个Git版本库。在类Unix系统下,我们可以执行以下命令:
$ mkdir testgit $ cd testgit $ git init 这样我们就在testgit目录中初始化了一个新的版本库。我们可以建立分支、提交、创建标签、和远程Git仓库进行交互。我们甚至可以和其他类型的版本控制系统进行交互,只需要借助若干git命令即可。
git init命令会在testgit目录下创建一个名为.git的子目录。我们来看一下这个目录的结构:
tree .git/ .git/ |-- HEAD |-- config |-- description |-- hooks | |-- applypatch-msg.sample | |-- commit-msg.sample | |-- post-commit.sample | |-- post-receive.sample | |-- post-update.sample | |-- pre-applypatch.sample | |-- pre-commit.sample | |-- pre-rebase.sample | |-- prepare-commit-msg.sample | |-- update.sample |-- info | |-- exclude |-- objects | |-- info | |-- pack |-- refs |-- heads |-- tags .git目录默认创建在工作区的根目录下,也就是testgit。它包含了以下几种类型的文件和目录:
配置文件: .git/config、.git/description、.git/info/exclude,这些文件会用来配置本地仓库。 钩子: .git/hooks目录下的脚本可以在Git运作的各个环节中得到执行。 暂存区: .git/index文件(它并没有在上述目录结构中显示出来)会用来保存工作区准备提交的内容。 对象数据库: .git/objects是默认的Git对象数据库存放目录,囊括了本地仓库的所有文件内容和指针。对象一经创建则不能修改。 引用:.git/refs目录用来存放本地和远程仓库的分支、标签、头指针等信息。"引用"表示指向某个对象指针,通常是tag和commit类型。引用之所以放置在对象数据库之外,是为了让他们能够随版本库的演进而变化。特殊的引用可以指向其他引用,如HEAD。 .git目录是真正意义上的版本库。工作区指的是包含所有工作文件的目录,它通常是.git目录的父目录。如果你需要创建一个没有工作区的远程仓库,可以使用git init --bare命令。它会直接在根目录下生成Git仓库的各类文件,而不是放置在一个子目录中。
另一个较为重要的文件是Git暂存区:.git/index。它在工作区和本地版本库之间增加了一个缓冲区,可以将需要提交的内容暂存在这里,最后一起提交。即使你对很多文件进行了修改,通过暂存区可以将它们作为一次完整的提交,并加注合理的注释。如果想将工作区某些文件的部分修改保存至暂存区,可以使用git add -p命令。
Git暂存区里的内容默认保存在单个文件中。版本库、暂存区、工作区的存放位置都是可以通过环境变量来进行配置的。
我们有必要了解一下以上三个区域的文件是如何进行交互的,以几个核心的Git命令举例:
git checkout [branch]
这条命令会将HEAD引用指向指定分支的引用(如refs/heads/master),并用该引用指向的内容替换掉暂存区和工作区中的内容。
git add [files]
这条命令会检验工作区中指定的文件和暂存区是否一致,若不一致则更新暂存区。版本库不会发生变化。
为了深入挖掘其中的原理,让我们看看.git目录下的文件都发生了哪些变化:
$ GIT_DIR=$PWD/.git $ cat $GIT_DIR/HEAD
ref: refs/heads/master
$ MY_CURRENT_BRANCH=$(cat .git/HEAD | sed 's/ref: //g') $ cat $GIT_DIR/$MY_CURRENT_BRANCH
cat: .git/refs/heads/master: No such file or directory 这里会返回一个错误信息,因为我们还没有在Git仓库中进行过任何提交,因此不会存在任何分支,包括默认分支master。
让我们进行一次提交,这时master分支会自动创建:
$ git commit -m "Initial empty commit" --allow-empty $ git branch
- master
$ cat $GIT_DIR/$MY_CURRENT_BRANCH
3bce5b130b17b7ce2f98d17b2998e32b1bc29d68
$ git cat-file -p $(cat $GIT_DIR/$MY_CURRENT_BRANCH) 输出的内容就是Git对象数据库中保存的信息了。