Git 的简单工作原理

在来到薄荷之前,我的工作中对代码审核等并没有一个规范的流程,所以对 Git 的使用也一直停留在几个基本的命令如add,commit的使用,而且对其背后的原理也不怎么理解。最近趁着每周坐公交车的时间,看了 Pro Git 这本小书的前三分之一的部分,算是对一些基本操作有了一些理解,今天就来与大家分享一下。

Git 简史

Git 诞生于一个极富纷争大举创新的年代。Linux 内核开源项目有着为数种广的参与者。绝大多数 Linux 内核维护工作都花在了提交补丁和保存归档的繁琐事物上。到 2002 年,整个项目组开始启用分布式版本控制系统 BitKeeper 来管理和维护代码。

到了 2005 年,BitKeeper 收回了 Linux 内核开源社区的免费使用权利。Linus 对此愤怒了,于是决定吸取教训,开发一套属于自己的版本控制系统。同时制定了如下的目标:

  • 速度
  • 简单的设计
  • 完全分布式
  • 对非线性开发模式的强力支持(允许上千个并行开发的分支)

自 2005 年诞生以来,Git 日臻完善成熟,在高度易用的同时仍然保留着初期设定的目标,即简单、速度。

Git 基础

在使用 Git 的时候,若是能对 Git 的思想和基本原理,用起来就会知其所以然、游刃有余。如果以前使用过 svn之类的版本控制系统的经验的话,在初次使用 Git 的话应该会有种似曾相识的感觉,因为很多命令都很像。但是这些命令只是封装之后的结果,其思想和原理是截然不同的。

  • 直接记录快照,而非差异比较
    • Git 只关心文件数据的整体是否发生变化,而大多数其他文件系统则只关心文件内容的具体差异
    • svn 这类系统每次记录有那些文件发生了更新,以及都更新了哪些行的什么内容,大致是下图
    • Git 并不保存这些前后变化的差异数据。实际上,Git 更像是把变化的文件作快照后,记录在一个微型的文件系统中。每次提交更新,它会纵览一遍所有文件的指纹信息并对文件作一快照,然后保存一个指向这次快照的索引。为提高性能,若文件没有变化,Git 不会再次保存,而只针对上次保存的快照作一次链接
  • 近乎所有操作都是本地执行
    • 因为 Git 在本地磁盘上就保存着所有当前项目的历史更新,所以处理起来速度飞快
    • 如果要浏览项目的历史更新摘要,Git 不用跑到外面的服务器上取数据回来,而直接从本地数据库读取后展示给你看
    • 没有网络也可以非常愉快地频繁提交更新
  • 时刻保持数据完整性
    • 在保存到 Git 之前,所有数据都要进行内容的校验和(checksum)计算,并将此结果作为数据的唯一标识和索引
    • Git 使用 SHA-1 算法计算数据的校验和,通过对文件的内容和目录的结构计算出一个 SHA-1 哈希值,作为指纹字符串。该字符串由40个十六进制(0-9 及 a-f)组成
    • 所有保存在 Git 数据库中的东西都是用此哈希值来作索引的,而不是靠文件名
  • 多数操作仅添加数据
    • 在 Git 里,一旦提交快照之后就完全不用担心丢失数据
  • 文件的三种状态
    • 已提交(committed),表示该文件已经被安全地保存在本地数据库中了
    • 已修改(modified),表示修改了某个文件,但还没有提交保存
    • 已暂存(staged),表示把已修改的文件放在下次提交时要保存的清淡中
  • Git 目录
    • 每个项目都有一个 Git 目录(.git目录),它是 Git 用来保存元数据和对象数据库的地方。该目录非常重要,每次克隆镜像仓库的时候,实际拷贝的就是这个目录里面的数据
    • 从项目中取出某个版本的所有文件和目录,这些文件实际上都是从 Git 目录中的压缩对象数据库中提取出来的,接下来就可以在工作目录中对这些文件进行编辑
    • 暂存区域只不过是个简单的文件,一般都放在 Git 目录中
  • 文件的状态变化周期

Git 收取的是项目历史的所有数据,服务器上所有的数据克隆之后本地也都有了。实际上,即便服务器的磁盘发生故障,用任何一个克隆出来的客户端都可以重建服务器上的仓库,回到当初克隆的状态。

Git 分支

几乎每一种版本控制系统都以某种形式支持分支。使用分支意味着你可以从开发主线上分离出来,然后在不影响主线的同时继续工作。在很多版本控制系统中,这是个昂贵的过程,常常需要创建一个源代码目录的完整副本,对大型项目来说会话费很长的时间。

而 Git 的分支模型被成为必杀技特性,正是因为它,将 Git 从版本控制系统家族里区分出来。Git 的分支有何特性呢?Git 的分支可谓是难以置信的轻量级,它的新创建几乎可以在瞬间完成,并且在不同分之间切换起来也差不多是一样快。理解分支的概念并熟练运用后,你才会意识到为什么 Git 是一个如此强大而独特的工具,并从此真正改变你的开发方式。

何谓分支

在 Git 中提交时,会保存一个提交(commit)对象,该对象包含一个指向暂存内容快照的指针,包含本次提交的作者等相关附属信息,包含零个或多个指向该提交对象的父对象指针:首次提交是没有直接祖先的,普通提交有一个祖先,由两个或多个分支合并产生的提交则有多个祖先。

下面举个例子:假设我们在工作目录中有三个文件,准备将它们暂存后提交。暂存会对每一个文件计算校验和(SHA-1哈希字符串),然后把当前版本的文件快照保存到 Git 目录中(Git 使用 blob 类型的对象存储这些快照),并将加入暂存区域。

当使用 git commit 新建一个提交对象前,Git 会先计算每一个子目录(本例中就是项目根目录)的校验和,然后在 Git 仓库中将这些目录保存为(tree)对象。之后 Git 创建的提交对象,除了包含相关提交信息以外,还包含着指向这个树对象(项目根目录)的指针。如此它就可以在将来需要的时候,重现此次快照的内容了。

现在,Git 仓库中有五个对象:

  • 三个表示文件快照内容的 blob 对象
  • 一个记录着目录树内容及其中各个文件对应 blob 对象索引的 tree 对象
  • 一个包含指向 tree(根目录)的索引何其他提交信息元数据的 commit 对象

两次提交后,仓库历史会变成下面这样

回来继续说分支。Git 中的分支,其实质上仅仅是个指向 commit 对象的可变指针。Git 会使用 master 作为分支的默认名字。在若干次提交后,你其实已经有了一个指向最后一次提交对象的 master 分支,它在每次提交的时候都会自动向前移动。

Git 又是如何创建一个新的分支呢?答案很简单,创建一个新的分支指针。比如创建一个 test 分支

那么,Git 是如何知道你当前在哪个分支上工作的呢?其实答案也很简单,它保存着一个名为 HEAD 的特别指针。它是一个指向你正在工作的本地分支的指针,假设当前我们在 test 分支,那么 HEAD 就指向了 test 分支

由于 Git 中的分支实际上仅是一个包含所指对象校验和(40个字符长度 SHA-1 字符串)的文件,所以创建何销毁一个分支就变得非常廉价。其实,先见一个分支就是向一个文件写入 41 个字节(外加一个换行符)那么简单,当然速度也就很快了。

这和大多数版本控制系统形成了鲜明的对比,它们管理分支大多采取备份所有项目文件到特定目录的方式,所以根据项目文件数目和大小不同,可能话费的时间也会有很大的差别,快则几秒,慢则几分钟。而 Git 的实现与项目复杂度无关,它永远可以在几毫秒内完成分支的创建和切换。同时,因为每次提交都保存了 parent 对象,将来要合并分支时,寻找恰当的合并基础的工作其实已经自然而然地摆在那里了,所以实现起来非常容易。 Git 鼓励开发者频繁使用分支,正是因为有着这些特性保障。