Git 的内部原理:一个内容寻址的文件系统
Git 是当今最广泛使用的版本控制系统。大多数开发者每天都会使用 add、commit、branch、merge、rebase 等命令,但对于这些命令背后究竟发生了什么,许多人并不十分清楚。这种不清楚往往在遇到问题时暴露出来:分离头指针是什么意思?为什么变基之后需要强制推送?删除的提交还能找回吗?为什么克隆一个仓库这么慢?
事实上,Git 的核心设计非常简洁。它的本质是一个内容寻址的文件系统,在此之上构建了一套版本控制的用户界面。一旦理解了这个核心模型,许多看似复杂的 Git 行为都会变得清晰而自然。
本文从 Git 的设计背景出发,介绍其对象模型、引用机制、暂存区、分支与合并的原理、变基的本质、存储优化与打包,以及分布式协作的工作方式。
一、设计背景
从快照到差异
在 Git 出现之前,许多版本控制系统以差异(Delta)为核心来存储历史:保存文件的初始版本,之后每次修改只记录与前一个版本的差异。要获取某个历史版本,需要从初始版本开始,依次应用所有的差异。
Git 采用了不同的思路:在概念上,每一次提交都保存项目在那一时刻的完整快照。如果某个文件在这次提交中没有发生变化,Git 不会再存储一份副本,而是直接引用之前已经存储的那份内容。
这种以快照为核心的模型,使得 Git 在许多操作上非常高效:切换到任意一个历史版本,只需直接读取那个版本的快照;比较任意两个版本,只需比较两个快照;创建分支只是创建一个指向某个快照的指针。
分布式
Git 是一个分布式版本控制系统。每个开发者的本地仓库都包含项目的完整历史,绝大多数操作,例如提交、查看历史、创建分支、比较差异,都在本地完成,不需要访问网络。仓库之间是对等的,任何一个仓库都可以从其他仓库获取更新,或者将更新推送给其他仓库。所谓的"中央仓库",只是团队约定的一个协作点,在技术上与其他仓库并无本质区别。
二、对象模型
Git 仓库的核心数据存储在 .git 目录下的对象数据库中。Git 中的所有内容,包括文件内容、目录结构、提交信息,都以对象的形式存储。
内容寻址
每个对象都由其内容的哈希值来标识。Git 将对象的类型、大小和内容组合在一起,计算出一个哈希值,作为这个对象的名称。传统上,Git 使用 SHA-1 哈希算法,生成 40 个十六进制字符的标识;新版本的 Git 也支持使用更安全的 SHA-256 算法。
这种内容寻址(Content-Addressable)的设计带来了几个重要的特性:
- 相同的内容只存储一次:无论一个文件在多少个提交、多少个目录中出现,只要内容相同,其哈希值就相同,在对象数据库中只有一份;
- 内容不可变:对象的名称由内容决定,修改内容就会得到一个不同的名称,即一个新的对象。已经存在的对象永远不会被修改;
- 完整性校验:读取一个对象时,可以重新计算其哈希值,与对象的名称进行比较,任何数据损坏都能被立即发现;
- 高效的比较:判断两个对象是否相同,只需比较它们的哈希值,而不必比较内容本身。
Git 中有四种基本的对象类型:数据对象、树对象、提交对象和标签对象。
数据对象
数据对象(Blob)存储文件的内容。
需要注意的是,数据对象只包含文件的内容,不包含文件名、路径、权限等任何其他信息。两个内容完全相同的文件,即使名称不同、位于不同的目录中,也对应着同一个数据对象。
树对象
树对象(Tree)表示一个目录。它包含一组条目,每个条目记录:
- 文件模式:例如普通文件、可执行文件、符号链接或子目录;
- 对象类型;
- 对象的哈希值;
- 文件名或子目录名。
树对象的条目可以指向数据对象,表示目录中的文件;也可以指向另一个树对象,表示子目录。通过这种嵌套,一个树对象可以表示整个目录层次结构。
与数据对象一样,树对象也是内容寻址的:如果一个目录的内容在两次提交之间没有任何变化,它对应的树对象就是同一个,可以被直接复用。
提交对象
提交对象(Commit)记录了一次提交的信息,包括:
- 一个树对象的哈希值,代表这次提交时项目根目录的完整快照;
- 零个或多个父提交的哈希值。初始提交没有父提交;普通提交有一个父提交;合并提交有两个或多个父提交;
- 作者的姓名、邮箱和时间:最初编写这些修改的人;
- 提交者的姓名、邮箱和时间:将这些修改提交到仓库的人。在大多数情况下,两者相同;但在变基、拣选提交等操作中,提交者会变为执行操作的人,而作者保持不变;
- 提交说明。
由于提交对象包含了父提交的哈希值,而它自身的哈希值又依赖于其全部内容,每一个提交的哈希值都间接地依赖于其全部的历史。如果历史中的任何一个提交被修改,其后所有提交的哈希值都会发生变化。这使得 Git 的历史天然具有防篡改的特性:只要知道最新提交的哈希值,就可以验证整个历史的完整性。
标签对象
标签对象(Tag)用于给某个对象,通常是一个提交,附加一个带有说明的名称。它包含被标记对象的哈希值、标签名称、创建者信息、标签说明,还可以包含数字签名。这种标签被称为附注标签。
对象之间的关系
以一个简单的项目为例,其中包含一个说明文件和一个源代码目录。一次提交在对象数据库中形成如下的结构:
提交对象
└─ 树对象(根目录)
├─ 数据对象(说明文件的内容)
└─ 树对象(源代码目录)
├─ 数据对象(第一个源文件的内容)
└─ 数据对象(第二个源文件的内容)
当修改了其中一个源文件并再次提交时:
- 被修改的源文件产生了一个新的数据对象;
- 源代码目录的内容变了,因此产生了一个新的树对象;
- 根目录的内容也随之改变,产生了一个新的根树对象;
- 生成一个新的提交对象,指向新的根树对象,并以上一个提交为父提交;
- 说明文件和未修改的源文件,直接复用原来的数据对象,没有产生任何新的存储。
所有的提交通过父提交的引用连接在一起,形成一个有向无环图(Directed Acyclic Graph,DAG)。这个提交图就是项目的版本历史。
底层命令
Git 提供了一组底层命令,可以直接操作对象数据库。例如,可以将任意内容写入对象数据库并得到其哈希值,可以根据哈希值查看任意对象的类型和内容,可以手动构建树对象和提交对象。通过这些底层命令,可以在不使用任何常规命令的情况下,手工完成一次提交。日常使用的高层命令,本质上都是对这些底层操作的组合和封装。
三、引用
对象是通过哈希值来访问的,但让人记住和输入 40 个字符的哈希值是不现实的。引用(Reference)为哈希值提供了人类可读的名称。
分支
在 Git 中,一个分支仅仅是一个指向某个提交的可移动的指针。
在底层,一个分支就是一个文件,文件名是分支的名称,文件内容是它所指向的提交的哈希值。创建一个分支,只是创建一个包含哈希值的小文件,几乎没有任何开销,这也是 Git 中创建和切换分支极其快速的原因。
当在一个分支上进行新的提交时,Git 会创建新的提交对象,以当前分支指向的提交为父提交,然后将分支指针移动到新的提交上。
HEAD
HEAD 是一个特殊的引用,表示"当前所在的位置"。在通常情况下,HEAD 是一个符号引用:它不直接包含哈希值,而是指向一个分支,例如"指向主分支"。当进行新的提交时,Git 通过 HEAD 找到当前的分支,然后移动该分支的指针。
切换分支时,Git 修改 HEAD 使其指向另一个分支,并更新工作目录中的文件,使其与该分支指向的提交的快照一致。
分离头指针
如果直接检出一个具体的提交,而不是一个分支,HEAD 就会直接包含这个提交的哈希值,而不是指向某个分支,这种状态称为分离头指针(Detached HEAD)。
在分离头指针状态下,仍然可以正常地进行修改和提交。新的提交会以 HEAD 当前指向的提交为父提交,然后 HEAD 移动到新的提交上。但由于没有任何分支指向这些新提交,一旦切换到其他分支,这些提交就不再被任何引用所指向,很难再找到它们,最终可能会被垃圾回收清除。
如果希望保留在分离头指针状态下所做的提交,只需在切换走之前,基于当前位置创建一个新的分支即可。
其他引用
- 标签:与分支类似,是一个指向特定对象的引用。区别在于标签是固定的,通常不会移动。轻量标签直接指向一个提交;附注标签指向一个标签对象;
- 远程跟踪分支:记录远程仓库中各个分支在上一次同步时的位置。它们在本地是只读的,只有在从远程仓库获取更新时才会被更新;
- 暂存记录:保存临时搁置的修改,其本质也是一些特殊的提交对象;
- 在执行一些可能具有破坏性的操作时,Git 会自动记录操作之前的位置,例如在合并、变基、重置之前记录原来的 HEAD,便于在需要时恢复。
引用日志
引用日志(Reflog)记录了每个引用,包括 HEAD 和各个分支,在本地仓库中的每一次变化:在什么时间、由于什么操作,从哪个提交移动到了哪个提交。
引用日志是 Git 的"安全网"。即使通过重置、变基等操作,使得某些提交不再被任何分支引用,只要它们还没有被垃圾回收清除,就可以通过引用日志找到它们的哈希值,重新创建分支指向它们,从而恢复看似丢失的工作。
需要注意的是,引用日志只存在于本地仓库中,不会被推送或克隆,并且其中的条目在一段时间后会过期。
引用的存储
当仓库中的引用数量很多时,例如有成千上万个分支和标签,将每个引用存储为一个单独的文件会带来性能问题。Git 会将引用打包到一个文件中。新版本的 Git 还引入了一种新的引用存储格式,以更高效地处理大量引用,并支持原子地更新多个引用。
四、三个区域
理解 Git 的日常操作,需要清楚 Git 中的三个区域:
- 工作目录(Working Directory):项目文件在磁盘上的实际副本,是直接编辑文件的地方;
- 暂存区(Staging Area),也称索引(Index):一个记录了"下一次提交将包含什么内容"的中间区域;
- 仓库(Repository):对象数据库和引用,保存着所有的提交历史。
暂存区的本质
暂存区在底层是 .git 目录中的一个二进制文件。它记录了一个扁平的文件列表,对于每个文件,记录其路径、对应的数据对象的哈希值、文件模式,以及一些文件系统的元数据,例如文件大小和修改时间。
暂存区实际上描述的是下一次提交的树对象的内容。
- 执行添加文件到暂存区的操作时,Git 读取工作目录中的文件内容,将其作为数据对象写入对象数据库,并在暂存区中更新该文件对应的哈希值。也就是说,在添加到暂存区时,文件的内容就已经被存入了对象数据库;
- 执行提交时,Git 根据暂存区中的文件列表,构建出相应的树对象,然后创建一个提交对象,指向这个树对象,最后更新当前分支的指针。
文件状态的判断
Git 判断文件状态,实际上就是在比较这三个区域:
- 比较 HEAD 指向的提交的树与暂存区:两者不同的文件,是已暂存的修改,将会进入下一次提交;
- 比较暂存区与工作目录:两者不同的文件,是已修改但未暂存的修改;
- 存在于工作目录中,但不在暂存区中,也没有被忽略规则排除的文件,是未跟踪的文件。
为了快速判断工作目录中的文件是否被修改过,Git 并不会每次都重新计算所有文件的哈希值。它会将文件当前的大小、修改时间等元数据,与暂存区中记录的值进行比较。如果这些元数据都没有变化,就认为文件没有被修改,从而避免读取文件内容。这使得在大型仓库中查看状态的操作依然很快。
暂存区的价值
暂存区的存在,让开发者可以精确地控制每一次提交包含哪些内容。即使在工作目录中同时修改了多个文件,进行了多项不相关的改动,也可以将它们分别加入暂存区,形成多个逻辑清晰、各自独立的提交。Git 甚至允许只暂存一个文件中的部分修改。
重置与恢复
理解了三个区域,就可以清楚地理解重置命令的不同模式。重置命令会将当前分支的指针移动到指定的提交,并根据模式决定是否同时更新暂存区和工作目录:
- 软重置:只移动分支指针。暂存区和工作目录保持不变,原来提交中的修改会表现为已暂存的状态;
- 混合重置(默认模式):移动分支指针,并将暂存区重置为与目标提交一致。工作目录保持不变,修改会表现为未暂存的状态;
- 硬重置:移动分支指针,并将暂存区和工作目录都重置为与目标提交一致。工作目录中所有未提交的修改都会被丢弃,这是一个具有破坏性的操作。
五、合并
三方合并
当需要将两个分支的修改合并在一起时,Git 使用三方合并(Three-Way Merge)。
除了两个分支各自的最新提交之外,Git 还会找到它们的合并基础(Merge Base),即两个分支在提交图中最近的共同祖先。然后,对于每一个文件,比较三个版本:
- 如果一个文件只在其中一个分支上被修改过,另一个分支上与合并基础相同,就采用被修改的版本;
- 如果一个文件在两个分支上被修改成了相同的内容,就采用这个内容;
- 如果一个文件在两个分支上都被修改了,并且修改的内容不同,Git 会进一步在行的层面进行比较:如果两边修改的是文件中不同的位置,就自动将两边的修改合并在一起;如果两边修改了同一个位置,就产生冲突,需要人工解决。
之所以需要合并基础,是因为只比较两个分支的最新版本,无法判断差异是由哪一方引起的。例如,一个文件中的某一行在一个分支中存在,在另一个分支中不存在:这可能是一个分支添加了这一行,也可能是另一个分支删除了这一行。有了合并基础,就可以知道这一行原本是否存在,从而做出正确的判断。
快进合并
如果被合并的分支是当前分支的直接后代,即当前分支的最新提交就是两者的合并基础,那么当前分支上没有任何对方没有的修改。此时,Git 只需将当前分支的指针直接向前移动到对方分支的最新提交,不需要创建任何新的提交。这称为快进合并(Fast-Forward Merge)。
合并提交
如果两个分支都有各自的新提交,Git 会在完成三方合并后,创建一个合并提交。合并提交有两个父提交,分别指向被合并的两个分支的最新提交。这样,提交图中就记录了两条开发线在此处汇合的事实。
合并策略
当两个分支之间存在多个同样近的共同祖先时,例如两个分支之间曾经发生过交叉合并,情况会变得复杂。Git 的默认合并策略会先将这些共同祖先递归地合并成一个虚拟的合并基础,再以它为基础进行三方合并。新版本的 Git 使用了一种经过重新实现的合并策略,在处理文件重命名等情况时更加准确,并且速度更快。
重命名检测
一个值得注意的设计是:Git 并不显式地记录文件的重命名。提交对象只记录快照,不记录"某个文件被重命名为另一个文件"这样的操作。
Git 是在需要的时候,例如比较差异和合并时,通过比较内容的相似度来推断重命名的:如果一个文件从某个路径消失了,同时在另一个路径出现了一个内容高度相似的文件,Git 就认为这是一次重命名。默认情况下,相似度超过一半即被视为重命名。
这种方式的好处是存储模型保持了简洁,并且即使用户使用普通的文件操作而不是 Git 的命令移动文件,也能被正确识别。缺点是,如果在重命名的同时对文件内容做了大量修改,相似度降低,Git 可能无法识别出重命名,而将其视为删除了一个文件并新增了另一个文件。因此,一个常见的建议是:重命名文件和大幅修改文件内容,最好分在不同的提交中进行。
六、变基
变基的本质
变基(Rebase)是另一种整合两个分支修改的方式。它的效果是:将当前分支上的一系列提交,"移动"到另一个分支的最新提交之后,使历史看起来就像是在对方分支的最新状态上进行的开发。
但如前所述,Git 中的对象是不可变的,提交不可能被真正"移动"。变基实际上执行的是以下过程:
- 找到当前分支与目标分支的合并基础;
- 找出当前分支上,从合并基础之后的所有提交;
- 将当前分支的位置重置到目标分支的最新提交;
- 依次将第 2 步找出的每一个提交所引入的修改,重新应用到当前位置,每应用一个,就创建一个新的提交。
新创建的提交,与原来的提交有着相同的修改内容、相同的提交说明和作者,但它们的父提交不同,所包含的树对象通常也不同,因此哈希值完全不同。它们是全新的提交对象。
原来的那些提交并没有被删除,它们仍然存在于对象数据库中,只是不再被当前分支引用。通过引用日志,仍然可以找到它们。
变基与合并的比较
合并和变基都能将两个分支的修改整合在一起,最终得到的代码内容通常是相同的,区别在于历史的形态:
- 合并保留了历史的真实面貌:两条开发线是并行进行的,在某个时刻被合并在一起。但当团队中有大量并行的分支频繁合并时,提交图会变得错综复杂,难以阅读;
- 变基产生了一条线性的历史:所有的提交排成一条直线,易于阅读和理解,便于逐个提交地审查和二分查找问题。但它改写了历史,抹去了这些提交原本是在另一个基础上开发的事实。
交互式变基
变基还有一种交互式的用法:在重新应用提交的过程中,允许对每个提交进行编辑。可以:
- 修改某个提交的说明;
- 将多个提交压缩合并为一个;
- 将一个提交拆分为多个;
- 调整提交的顺序;
- 删除某个提交;
- 在某个提交处暂停,进行额外的修改。
交互式变基常用于在将本地的工作提交给他人审查或合并到主分支之前,整理提交历史,使其更加清晰:将开发过程中零散的"修正拼写错误""再修改一下"之类的提交合并到相应的主要提交中,使每一个提交都代表一个完整、独立的逻辑修改。
变基的黄金法则
由于变基会创建新的提交,并让原分支指向这些新提交,它会改写历史。这引出了使用变基最重要的一条原则:
不要对已经推送并且可能被他人使用的提交进行变基。
假设一个开发者已经将一些提交推送到共享仓库,其他人已经基于这些提交进行了自己的开发。如果这个开发者在本地对这些提交进行了变基,然后强制推送到共享仓库,那么共享仓库中的分支就指向了一组全新的提交。其他人的本地仓库中,仍然保留着原来的提交,并在其上进行了开发。当他们再次同步时,Git 会认为这是两组不同的提交,可能导致相同的修改出现两次,或者产生大量令人困惑的冲突。
这也是为什么对已推送的分支进行变基后,需要使用强制推送:因为远程分支所指向的提交,不再是本地分支历史的祖先,普通的推送会被拒绝,以防止意外覆盖他人的工作。在必须强制推送的情况下,应当使用带有保护的强制推送选项,它只有在远程分支仍然处于自己预期的位置时,才会执行覆盖,避免意外地覆盖他人在此期间推送的提交。
拣选提交
拣选(Cherry-Pick)是与变基相关的一个操作:将另一个分支上的某一个特定提交所引入的修改,应用到当前分支上,创建一个新的提交。变基在本质上可以看作是对一系列提交依次进行拣选。拣选常用于将某个修复从开发分支移植到发布分支上。
七、存储与打包
松散对象
当新的对象被创建时,Git 会将其内容压缩后,存储为对象目录中的一个单独文件,称为松散对象。为了避免单个目录中的文件过多,Git 使用哈希值的前两个字符作为子目录名,其余的字符作为文件名。
快照的存储问题
前面提到,Git 在概念上为每次提交保存完整的快照。即使通过内容寻址避免了存储未修改的文件,对于被修改的文件,每次修改仍然会产生一个新的完整数据对象。如果一个大文件被反复修改,每次只改动其中很小的一部分,以完整快照的方式存储会造成大量的空间浪费。
打包文件
为了解决这一问题,Git 会定期将松散对象打包成打包文件(Packfile)。在打包时,Git 会:
- 将对象按照类型、文件名、大小等特征进行排序,使相似的对象相邻;
- 对于相邻的相似对象,尝试将其中一个存储为相对于另一个的差异,称为增量对象;
- 对所有内容进行压缩。
与传统版本控制系统的一个重要区别是:Git 的差异压缩完全是存储层的优化,与对象模型和提交历史无关。增量对象的基础对象不一定是同一个文件的上一个版本,可以是任意一个内容相似的对象;并且 Git 通常会让较新的版本保存为完整内容,较旧的版本保存为差异,因为较新的版本更常被访问。对于使用者而言,对象仍然是完整的快照,打包与差异压缩是完全透明的。
每个打包文件都配有一个索引文件,记录了每个对象在打包文件中的偏移位置,可以通过二分查找快速定位任意一个对象。
垃圾回收
Git 会在适当的时候自动执行垃圾回收:
- 将松散对象打包;
- 将多个打包文件合并;
- 删除不可达的对象。从所有引用出发,包括分支、标签、引用日志,凡是无法到达的对象,在经过一段时间的宽限期后会被删除。
大型仓库的优化
对于包含大量文件或悠久历史的大型仓库,Git 提供了多种优化手段:
- 提交图文件:将提交图的结构,包括每个提交的父提交、提交时间和在图中的代数等信息,预先计算并存储在一个专门的文件中。在查看历史、计算合并基础、判断两个提交之间的祖先关系时,不再需要逐个解析提交对象,速度大幅提升;
- 多打包索引:为多个打包文件建立一个统一的索引,避免在大量打包文件中逐个查找对象;
- 可达性位图:为某些提交预先计算其所有可达对象的位图。在克隆或获取时,可以快速确定需要传输哪些对象;
- 文件系统监视:借助操作系统提供的文件变化通知,只检查发生了变化的文件,而不必扫描整个工作目录,加快查看状态等操作;
- 稀疏检出:只在工作目录中检出仓库的一部分目录,对于巨型仓库中只关心其中一部分的开发者非常有用;
- 部分克隆:在克隆时不下载全部的对象,例如不下载历史中的大文件内容,只在实际需要时再从远程获取;
- 浅克隆:只获取最近的若干次提交,而不是完整的历史。
大文件
Git 的设计主要针对文本文件。对于大型二进制文件,例如图片、视频、模型文件、编译产物,Git 的存储效率很低:二进制文件难以有效地进行差异压缩,每次修改都会产生一个新的完整对象,并且所有历史版本都会永久保存在仓库中,被每一个克隆仓库的人下载。
对于这类文件,常见的做法是使用专门的大文件存储扩展:在仓库中只保存一个很小的指针文件,记录大文件内容的哈希值,而大文件本身存储在一个单独的服务器上,在检出时按需下载。
八、分布式协作
远程仓库
远程仓库是指托管在其他位置的同一个项目的仓库。本地仓库可以配置多个远程仓库,每个远程仓库有一个名称和地址。
获取与推送
获取(Fetch):从远程仓库下载本地仓库中没有的对象,并更新远程跟踪分支,使其反映远程仓库中各个分支的当前位置。获取操作不会修改本地分支和工作目录,是完全安全的操作。
拉取(Pull):先执行获取,然后将远程跟踪分支合并到当前分支,或者以变基的方式整合。
推送(Push):将本地的提交和相关对象上传到远程仓库,并请求远程仓库更新相应的分支,使其指向本地分支的位置。默认情况下,远程仓库只接受快进式的更新:新的提交必须是远程分支当前所指向提交的后代。这保证了推送不会丢弃远程仓库中已有的提交。如果在推送之前,其他人已经向同一个分支推送了新的提交,推送会被拒绝,需要先获取并整合这些新的提交,再进行推送。
传输协议的协商
在获取和推送的过程中,Git 需要确定哪些对象需要传输。由于对象是内容寻址的,并且提交之间形成了一个图,这一过程可以高效地完成:
- 双方交换各自引用所指向的提交;
- 客户端告知服务器自己已经拥有哪些提交,服务器据此找出双方共同拥有的提交;
- 服务器计算出从对方需要的提交出发、可以到达但对方尚未拥有的所有对象;
- 服务器将这些对象打包成一个打包文件,发送给客户端。打包时同样会使用差异压缩,并且可以利用客户端已有的对象作为差异的基础,进一步减少传输的数据量。
由于对象由内容的哈希值标识,任何一方都可以准确地判断自己是否已经拥有某个对象,无需传输内容进行比较。
工作流
Git 的灵活性支持多种不同的协作工作流:
- 集中式工作流:所有开发者都与一个中央仓库同步,类似于传统的集中式版本控制系统;
- 功能分支工作流:每个功能或修复在独立的分支上开发,完成后通过代码审查合并到主分支;
- 派生工作流:在开源项目中常见。贡献者没有主仓库的写权限,他们先派生出自己的仓库副本,在副本上开发,然后请求主仓库的维护者合并自己的修改;
- 主干开发:开发者频繁地将小的修改直接合并到主分支,配合功能开关来控制未完成功能的可见性,避免长期存在的分支带来的合并困难。
九、安全性与完整性
哈希与信任
Git 的内容寻址机制,使得整个历史的完整性可以通过最新提交的哈希值来验证。这在很大程度上依赖于哈希函数的抗碰撞性:攻击者无法构造出两个内容不同但哈希值相同的对象。
Git 长期以来使用的 SHA-1 算法,已经被证明在理论上和实践中都可以构造出碰撞。为此,Git 采取了两方面的措施:一方面,使用一种经过加固的 SHA-1 实现,能够检测出已知的碰撞攻击模式,并拒绝可疑的对象;另一方面,Git 增加了对 SHA-256 的支持,以从根本上提高安全性。由于哈希算法的变化会影响所有对象的名称,向新算法的迁移需要考虑与现有仓库和工具的兼容性,是一个渐进的过程。
签名
哈希值能够保证内容没有被篡改,但无法证明是谁创建了这些内容。提交中的作者信息只是一个文本字段,任何人都可以将其设置为任意的名称和邮箱。
为了证明提交或标签确实来自声称的作者,Git 支持对提交和标签进行数字签名。签名使用作者的私钥生成,其他人可以使用对应的公钥进行验证。许多代码托管平台会在界面上显示签名的验证状态。
敏感信息的处理
由于 Git 保存了完整的历史,一旦敏感信息,例如密码、密钥、令牌,被提交到仓库中,即使在后续的提交中将其删除,它仍然存在于历史中的某个提交里,任何获得仓库副本的人都可以找到它。
要从历史中彻底删除敏感信息,需要改写整个历史,生成一组不包含该信息的全新提交,然后强制推送,并要求所有协作者重新克隆仓库。但这一过程代价很高,而且无法保证已经被他人获取的副本也被清除。因此,一旦发生了敏感信息的泄露,最重要的措施是立即使该信息失效,例如更换密码、吊销密钥,而不是仅仅试图从历史中删除它。
预防胜于补救:使用忽略文件排除包含敏感信息的配置文件,在提交前使用自动化工具扫描可能的敏感信息,都是有效的预防措施。
十、实践建议
理解对象模型。 许多 Git 的行为,在理解了对象、引用和提交图的模型后,都会变得一目了然。遇到困惑时,查看提交图的结构,往往能找到答案。
编写有意义的提交。 每个提交应当代表一个完整、独立的逻辑修改,并配有清晰的说明,解释这次修改做了什么以及为什么这样做。良好的提交历史,在日后追溯问题、审查代码、回滚修改时都非常有价值。
谨慎改写共享的历史。 可以自由地在本地整理尚未推送的提交,但不要对已经推送并被他人使用的提交进行变基或修改。必须强制推送时,使用带有保护的强制推送选项。
利用引用日志恢复工作。 当看似丢失了提交时,不要慌张。只要它们曾经存在于本地仓库中,并且尚未被垃圾回收,就可以通过引用日志找回。
善用二分查找。 当发现某个问题是在历史中的某个时刻引入的,但不知道具体是哪个提交时,可以使用 Git 的二分查找功能:指定一个已知正常的提交和一个已知有问题的提交,Git 会自动选择中间的提交让你检查,每次检查都将范围缩小一半,能够在很少的步骤内定位到引入问题的提交。线性、原子化的提交历史会让二分查找更加有效。
不要将大文件和生成的文件纳入版本控制。 编译产物、依赖包、日志、大型二进制文件,应当通过忽略文件排除,或者使用专门的大文件存储方案。
保护敏感信息。 不要将密码、密钥等敏感信息提交到仓库中,使用环境变量或专门的密钥管理系统。在提交前进行检查,一旦发现泄露,立即使其失效。
结语
Git 的核心是一个简洁而优雅的模型:一个由内容哈希寻址的对象数据库,存储着不可变的数据对象、树对象和提交对象;一组指向提交的可移动的引用,为这些对象提供人类可读的名称;以及一个暂存区,用于准备下一次提交的内容。
在这个模型之上,分支只是一个指针,创建它几乎不需要任何代价;合并是基于共同祖先的三方比较;变基是将一系列修改重新应用到新的基础上,生成新的提交;打包和差异压缩是对存储的透明优化;分布式协作则依赖于内容寻址带来的高效的对象交换。
许多人觉得 Git 难以掌握,往往是因为试图记住各种命令和选项的用法,而没有理解它们背后共同的数据模型。一旦理解了这个模型,就会发现那些看似复杂的命令,不过是在以不同的方式创建对象、移动指针。这种理解,不仅能帮助更自信地使用 Git,也能在出现问题时,清楚地知道发生了什么,以及如何修复它。
- 点赞
- 收藏
- 关注作者
评论(0)