对于从事软件研发工作的开发者或者在校学习计科/软工专业的学生,在一门叫做《软件工程》的课程中一定听到过“版本控制系统”的概念。版本控制系统在当今的软件研发中被广泛使用,已经成为了不可或缺的技能。我们的课程首先就从这里开始:什么是版本控制系统?

什么是版本控制系统

版本控制系统在英文中可以用Version Control System或者Revision Control System来表示1。对很多人来说,从“版本控制系统”翻译到英文,可能首先想到的会是前者,但可能后者更为合适。Version是版本,Revision更倾向于修订版本的意思。即version更适合表示发行的版本,而revision是在其后的某次修订版本。依我个人的理解,在git中更愿意使用revision表述每个commit节点,使用version表示tag。

那么什么是版本控制系统呢?

版本控制系统是一组用来管理文档,软件源码等信息的软件集合,可以对这些信息的版本进行管理。

版本控制系统是一组软件的集合。就是说,Git作为一款版本控制系统,其本质上是多个软件的集合,这一点我们后面会说到。

版本控制系统是对版本进行管理的软件的集合,所以我们需要先弄懂一个概念:什么是版本?

什么是版本

所谓版本,就是对当前内容的备份。比如为了方便地安装windows系统,采用一种叫做Ghost的软件提前将安装好的Windows系统备份在硬盘或者DVD之类的地方存储,当系统遭受病毒攻击等问题时,再将这份备份还原至硬盘中,这样就能够快速得到一个全新干净的系统,而这种备份事实上就是一个版本;很多人在修改配置文件,比如在Linux下修改bash的配置文件或者host文件时,会复制一份当前的配置文件作为备份,这样修改错误后可以立即还原,这份备份的配置文件也是一个版本。

在大一的时候我还不知道有版本控制系统这样的好东西,那时候和同学为了完成一个项目作业,是采用QQ文件传送或者百度网盘的方式来完成的。同学A在完成自己的代码编写以后将修改部分发给同学B,同学B将该部分合并到自己的工程中,再把自己修改的部分发给同学A。在两个人合作开发小项目的时候还能勉强应付,一旦参与人数多了,或者项目比较庞大时对版本的管理就会成为一个很棘手的问题。

还有一种情况相信大家在平时的开发/学习中也会遇到:有时候我们会对同一个需求采用不同的写法,也许是为了优化,也许是为了实验自己新学到的知识,于是我们可能会写出两段代码,然后屏蔽一段,保留另一段进行调试,再颠倒过来继续调试。在这个过程中我们往往会变得小心翼翼,特别是涉及到的代码量稍大一点时,一不小心可能会影响到其他代码的执行,甚至严重时会毁掉整个工程的编写,因此所带来的重复工作量可能是巨大的。这时候我们就需要版本控制系统提供给我们的回退功能以及分支功能。

版本控制系统就是把上述的版本控制行为通过一组软件进行管理。

什么是Git

我知道很多人并不喜欢课程中关于介绍的部分,包括我也是。在上学的时候,每门课程的第一节课我总是信心满满地坐在教室里,期待着新学期新课程的开始。但是每次我都会被老师冗长且无用的介绍折磨地失去耐心。有的老师会介绍一堆漫长的发展历史,有的老师则会一点而过地介绍一堆我们尚无法理解的概念。这些概念一般都很关键,但对于初学者而言提出一些概念而不细致地讲解则是一种折磨,总是让我产生一种老师没有准备好教案、拖延时间的感觉。

为了避免这样的问题,这一节我们会分成两部分来介绍:

  1. Git历史
  2. Git特性

如果你愿意了解关于Git的一些周边内容,欢迎阅读第1部分的科普性内容,如果没有兴趣或多余的时间也可以完全放弃这部分的内容,这并不会对后面的内容产生什么影响。

第2部分内容是非常关键的,它可能是你入门Git的关键。Git只是一个软件集合,并不是编程语言、算法数据结构、编译原理这样复杂的理论知识。Git只是一组软件集合,命令的数量是固定的,甚至在日常开发的使用中所用到的命令数量更少。但是依我平常的观察来看,被Git困扰的人并不少,也有很多人使用Git的习惯并不是很好。其中的问题就在于对概念的理解不够充分,尤其是很多开发者使用了多年的Subversion,他们会用svn的知识来套用Git。这是一种非常不好的习惯:因为svn和git的设计理念有很大的不同,这种方式会让git的学习曲线升高。

因此希望大家可以认真阅读第2部分的内容,一旦弄懂了这些概念,后面Git的学习就会变得非常轻松。

Git历史

李纳斯(Torvalds Linus)

自2002年开始,李纳斯决定使用BitKeeper作为Linux内核主要的版本控制系统用以维护代码2。因为BitKeeper为专有软件,这个决定在社区中长期遭受质疑。在Linux社区中,特别是理查德·斯托曼与自由软件基金会的成员,主张应该使用开放源代码的软件来作为Linux核心的版本控制系统。李纳斯曾考虑过采用现成软件作为版本控制系统(例如Monotone),但这些软件都存在一些问题,特别是性能不佳。现成的方案,如CVS的架构,受到李纳斯的批评3

2005年,安德鲁·垂鸠写了一个简单程序,可以连接BitKeeper的存储库,BitKeeper著作权拥有者拉里·麦沃伊认为安德鲁·垂鸠对BitKeeper内部使用的协议进行逆向工程,决定收回无偿使用BitKeeper的授权。Linux内核开发团队与BitMover公司进行蹉商,但无法解决他们之间的歧见。

所以说大牛的思维方式是我们很难理解的,特别是大神中的大神——李纳斯(Torvalds Linus)。无法解决分歧是吧?行,那我自己做一个,而且一旦做出来就肯定比你这个好,于是Git诞生了。历史的经验无数次地告诉我们,这种人一旦怒发冲冠是很可怕的,就比如理查德·斯托曼开发的gcc,以及比尔·乔伊开发的BSD Unix。

我始终觉得Linux和Git这种足以改变世界的东西,任何人尽其一生能够完成一件就了无遗憾了,而Torvalds Linus,他完成了两件。

介绍Git的历史,特别是Git的作者很重要,因为这和Git的特性有关。

Git特性

在这一节中,我们不会涉及到任何命令的讲解,而是重点介绍Git的概念和设计理念。

如果读者有过其他版本控制系统的使用经验,那么请从现在开始不要把这些经验运用到Git的学习中,也不要采用类比的方式学习,请尽量和初次接触版本控制系统的开发者一样理解Git的特性。

我们已经知道Git是一个版本控制系统软件的集合,那么首先让我们思考一个问题:Git是如何存储版本的呢?

Git存储模型

如何存储版本是一个并不困难的问题,实在不行我们可以在磁盘中创建多个文件夹来记录多个版本。但困难的是,如何高效地存储每一个版本呢?

上一小节提到Git的作者是Linus,那么这和Git的特性有什么关系呢?Linus上一个伟大的作品是Linux(这句写出来真让人羡慕嫉妒恨),如果学习过Linux,你肯定会知道其中所使用的Ext文件系统。Linux使用EXT文件系统来管理磁盘中的文件和文件夹,同理Git也使用文件系统的理念来管理版本。

对象

在Git中,存储的最小单元是对象(object),git仓库中的一切都是由对象构成的。

在Git中,你的每个版本,每个文件夹,每个文件都是一个对象,总共有四种对象:

每个对象都会记录自己的类型。

每个对象的名称是通过一个40个字符(40-digit)的“对象名”来索引的,对象名看起来像这样: 6ff87c4664981e4397625791c8ea3bbb5f2279a3。你会在Git里到处看到这种40个字符字符串。每一个对象名都是对对象内容做SHA1哈希计算得来的,(SHA1是一种密码学的哈希算法)。这样就意味着两个不同内容的对象不可能有相同的对象名

这样做的好处是什么呢?

  1. Git只要比较对象名,就可以很快的判断两个对象是否相同。
  2. 因为每个仓库(repository)的对象名计算方法都完全一样,如果同样的内容存在于两个不同的仓库中,就会存在相同的对象名。
  3. Git还可以通过检查对象内容的SHA1的哈希值和对象名是否相同,来判断对象内容是否正确。

就是说对自身来说,对象可以校验内容是否正确;对于同仓库的两个不同对象,可以通过对象名来区分;不同仓库的两个对象也可以比较是否相同。

接下来让我们看看这四种类型的对象都是什么。

blob

blob用来存储文件数据,通常是一个文件。

blob就是很普通的二进制文件,但是请始终记得所有的对象都有一个40字符的对象名以及都会记录它自身的类型,以下不再赘述。

tree

tree对象

tree有点像一个目录,它管理一些tree或是 blob(就像目录和目录中的子目录以及文件那样)。一个tree对象有一串指向blob对象或是其它tree对象的指针,它一般用来表示内容之间的目录层次关系。一个tree对象可以指向一个包含文件内容的blob对象, 也可以是其它包含某个子目录内容的其它tree对象。

commit

commit对象

一个commit只指向一个tree,它用来标记项目某一个特定时间点的状态。它包括一些关于时间点的数据,如时间戳、作者、指向上次提交(可能多个)的指针等等。

一个提交由以下的部分组成:

tag

一个tag对象包括一个对象名(SHA1签名)、对象类型、tag名、tag创建人的名字, 还可能包含一条签名信息。

版本存储方式

我们前面介绍了Git使用对象进行存储,通过对四种对象类型的介绍,我们可以了解到一个重要的知识点:Git存储的每个版本(即commit)并不关心版本之间的差异,而是关心文件数据的整体是否发生变化

很多版本控制系统记录的是当前版本和上一个版本的差异。所谓差异,是指:当前版本跟上一个版本相比新增了什么内容,移除了什么内容。

Git 并不保存这些前后变化的差异数据。实际上,Git 更像是把变化的文件记录在一个微型的文件系统中。每次提交新版本,它会检查一遍所有文件的信息并逐一保存,然后记录这些文件的索引(tree或blob的对象名)。为提高性能,若文件没有变化,Git 不会再次保存,而只对上次保存的信息作一次链接(类似指针的引用)。

版本迭代

从这张图里可以看到,从version1到version5的版本迭代过程中Version2和Version3的B文件,以及Version4的C2文件和Version5的A2文件(即虚线框标记的部分)都是和上一个版本相同的文件,因此在当前版本中并不会再次记录他们的内容,而是引用上一个版本的文件内容。

注: 关于这部分内容之所以要着重拿出来强调,是因为在日常的工作中,经常能够看到Git初学者在说:我提交了XXX文件,他们在版本迭代的过程中依然是以文件为粒度进行思考的,而不是站在版本的角度进行思考。这会导致在针对版本的操作(例如分支合并、版本回退)时,会局限于某个文件的变化,从而忽略整个版本的变化。

Git存储模型小结

这一节介绍了Git的存储模型。Git对内容的存储是通过对象完成的。每个对象都有一个40字符的对象名,该对象名可以唯一表示该对象。对象名包含该对象类型的信息,通过对象名的比较也可区分是否为同一个对象。

对象有四种类型,分别为blob, tree, commit和tag。

几乎所有的Git功能都是使用这四个简单的对象类型来完成的。它就像是在你本机的文件系统之上构建一个小的文件系统。

附上一张commit和tree以及blob的概览图:

概览图

最后我们介绍了Git中非差异化存储的方式,这意味着在版本的层面上,commit对象是我们可以操作的最小单元。也就是说,对于版本的更新、回退,分支切换等日常使用Git的大部分操作来说,我们应该考虑的是commit对象以及tag对象,而不是blob对象和tree对象。

分布式版本控制系统

Git是一种分布式版本控制系统(Distributed Version Control System)。什么是分布式版本控制系统呢?和分布式版本控制系统相对应的是集中式版本控制系统(Centralized Version Control System),让我们先从集中式版本控制系统开始介绍。

事实上还有一种是本地版本控制系统(Local Version Control System),很简单,这是一种只存在于本地的版本控制系统。这种版本控制系统在现今的软件工程应用中实际意义并不大,因此不再赘述。

所谓集中式版本控制系统,就是说有一个中心的服务器,在该服务器中存储着所有的版本。开发者终端每次从中心服务器中取到一个版本,并提交新的版本到这个服务器中。开发者终端并不会存储所有的版本。这样做的好处是,开发者每次只需要在本地保有一个版本,但弊端在于,一旦中心服务器出现任何问题,所有的版本都会丢失。

集中式版本控制系统

而在分布式版本控制系统中,并没有一个作为中心服务器的角色存在。事实上每个终端都可以作为服务器,每个终端都保有当前版本系统中的所有版本,每个终端都可以提交或接受新的版本。

尽管分布式版本控制系统下的每个终端都可以作为服务器,但是在实际的工程应用中,由于多人参与版本的修改,大家不可能互相提交版本到对方的终端去。因此在分布式版本控制系统中也会存在专门的服务器用于其他终端版本的更新,但服务器中的版本和其他终端的版本没有任何区别

分布式版本控制系统

注意,Computer A和Computer B之间是有交互的。

根据我身边同事朋友的反馈,很多初学者都在纠结于分布式版本系统的概念。他们会困惑于为什么分布式版本控制系统会需要一个集中的服务器?这台集中服务器和集中式版本控制系统中的服务器又有什么区别?事实上这涉及到Git学习中非常重要的一点:技术可以做到的事情是很多的,但在实际应用中我们会有规范来约束技术的使用。因此在后面的介绍中我们会看到一些技术上明明可行,但规范上严令禁止的操作,这些操作即使执行了也不会被Git驳回,但会对版本控制系统的使用造成隐患。

文件的三种状态

关于文件的三种状态,是Git入门最关键的概念。

文件的三种状态是指:

在讲解文件的三种状态前,需要提醒的是:请记住前面版本存储方式一节所提到的概念。尽管我们这里讨论的是关于文件的状态,在始终需要记得在Git中所有的文件是按照版本进行存储的。

已修改

基于当前版本下,我们的文件进行修改时,这些文件就会处在已修改的状态。

处在已修改状态下的文件,可以对其进行还原操作。使其从已修改的状态还原成当前版本的状态。

已暂存

对于已经修改的文件,可以将其添加到暂存区域。顾名思义,暂存区域是指在正式提交一个版本之前暂时存储的状态。对于这个状态下的文件,可以回退到已修改的状态,但是无法从已暂存状态直接还原到当前版本的状态。

已提交

从已暂存状态进行提交时,文件就会处于已提交的状态存储于新的版本中。就是说,已提交状态是指当前文件(夹)的内容已经成为某一版本对象(commit对象)中所存储的对象,即tree或者blob对象。

任何版本下的任何文件都处于已提交状态(即未修改,更未暂存),历史版本4中的任何文件无法从已提交状态转换为其他状态,而当前版本下处在已提交状态的文件是可以对其状态进行变更的。当然,历史版本也可以通过操作转换成当前版本。关于这部分的内容,我们会在版本回退部分介绍。

.git目录

已提交的文件形成了一次commit对象,那么这个对象放在哪里呢?这个对象会存储在一个叫做.git的文件夹中,事实上不仅仅是已提交的文件,已暂存和已修改的文件都会在.git目录中进行记录,甚至包括git的配置等等内容也会存储在其中。

.git目录处于工作文件夹(Working Directory)的根目录中。工作文件夹即版本控制系统所控制范围内最顶层的目录。

出于本课程用于初学者尽快入门的目的,关于.git目录的介绍不会着墨太多,这会涉及到较多Git的实现原理,我们在此省略,以免对初学者造成不必要的困扰。

文件的第四种状态

如果仅仅介绍了这三种状态,肯定会感到很疑惑:刚刚创建的文件属于什么状态呢?答案是都不属于。所以我将新创建且不处于已修改状态的情况称为第四种状态。而在Git的文档中没有提及这一情况也是可以理解的:此时该文件并未纳入Git的版本控制中,和Git无关,因此不应当算作Git的文件状态之一。

对于已修改状态的文件和新创建的文件,都可以使用相同的操作转成已暂存的状态。

文件的三种状态小结

在这一小节中我们介绍了文件的三种状态。这个概念非常重要,需要牢牢记住三种状态和它们之间的转换关系,这样我们在后面介绍一些命令操作时就会非常轻松。

一次简单的提交流程如下:

  1. 修改或者创建新文件
  2. 将已修改状态和新创建的文件转换成已暂存的状态
  3. 将已暂存状态的文件转换成已提交状态,创建出一个新的版本

最后给出一张三种状态之间的关系图:

文件的三种状态和状态的转换

总结

在这节课的内容中,我们介绍了Git一些比较重要的概念。为了初学者尽快入门,我们没有介绍过多底层实现细节,也没有介绍Git命令。但是这一节的内容仍然非常重要,它是后续学习的关键。请完全掌握和理解所介绍的内容,这样你会发现,后续学习Git是一件非常愉快的事情。

接下来我们会从Git的安装配置开始慢慢介绍Git的技术层面。

  1. https://en.wikipedia.org/wiki/Version_control 

  2. https://zh.wikipedia.org/wiki/Git 

  3. https://git.wiki.kernel.org/index.php/LinusTalk200705Transcript 

  4. 此处的历史版本是指非当前版本的某一commit对象。而当前版本是指HEAD节点,此处尚未介绍HEAD的概念,可以简单理解成:最新的,最后一次提交的版本(commit对象)。