理解 Git 存储的持久数据

2023.12.15

根据 2022 年 StackOverflow 社区的软件开发者反馈, Git 、 SVN 、 Mercurial 等版本控制工具里, Git 的使用者是最多的。不过很多人觉得 Git 相比于其他版本管理工具复杂,本文从 Git 所存持久数据的角度,来理解和解释 Git 的基本协作流程。

Git 存储的数据有整个项目的变更历史,一个 Git 提交 (commit) 相当于项目历史某个时刻的快照。按照时间关系,先后出现的快照间往往有依赖关系,所以通常要求用户协调好工作任务,以避免修改同一任务模块,而造成合并冲突。

最典型的任务冲突场景是不同的协作者修改同一个文件,有时是同一行,后者 Git 没法自动处理冲突,前者有些情况下可以使用 git 提供的工具自动处理,但需要协作者小心操作。为了更平滑的使用 Git 进行项目协作,稍微花些时间了解一下 Git 的数据存储方式很有必要。在某些操作需要做很多次的时候,再学习复杂的交互式操作,多操作几次之后应该就熟练了。

Git 存储到底是什么

由于 Git 非常开放和灵活,现在甚至有很多非软件开发项目在使用 Git 来存储数据, Git 的官方网站文档里说从根本上来说 Git 是一个提供了版本控制系统接口的内容可寻址文件系统

这是什么意思呢?

版本控制系统,这是对渐进式项目非常实用的管理工具,使用它可以方便地

内容,用户生成的数据,最典型的是软件源代码、文本文档、甚至是二进制数据(比如 PDF 文档、图像、音频或视频)。

寻址,类似 Git 这样的版本控制系统,它数据里存储的内容是通过内容的地址读取,它的地址是一个哈希值,为了方便人类检索里面存储的数据,它们也有像 v-2.7.1 、 1.0 版和 2023-10-24 版这样的符号地址,它们是指向一个具体哈希值的符号名称 (symbolic name) 。

文件系统,基于硬盘等物理设备的逻辑存储系统,可以方便的用工具存取数据。

接口( Interface ),有时候叫界面,比如 GUI (Graphic User Interface) ,图形界面, CLI (Command Line Interface) 或者 TUI (Terminal User Interface) ,命令行界面。典型的比如给排水管、是用水户的上下端接口,一般只需要确保水管的规格和给水和排水端匹配,用水户无需关心水厂如何处理水等其他细节;文件系统的命令行接口最常见的有 Linux 上的 touch (创建文件)、 ls (列出目录里的文件)、 rm (删除文件或目录)、 cd (变更目录)等。 Git 的版本控制系统接口,主要应该是指它可以方便的查看内容的变更、以及导入和生成开源项目常用的补丁格式,方便和传统的打补丁( patching )流程结合,通过创建新分支尝试新想法也很容易。

Git 数据结构

Git 存储的数据是一棵典型的哈希树,第一个快照就是项目第一次提交,是哈希树的根节点,它是后续其他快照的祖先节点。

在接收到 Git 项目数据的用户或协作者看来,Git 的项目数据是一棵不可变的树,「不可变」是指我们很难再复现某次快照中数据变更集合算出的哈希值。如果使用 force push 强推,会导致从强推的那个快照以及之后的项目历史被改写,那个快照有点像多重宇宙的分叉点,这种情况会造成协作者各个副本数据之间的不一致,有时会形成冲突。

熟悉 Git 这个数据结构之后,每次使用 Git 的工具操作其哈希树的时候,就想一下工具会产生什么效果 (effects) ,或者某操作如何修改 (mutate) Git 的哈希树,这样对理解项目的数据帮助很大。在 Git 官方网站的文件状态生命周期图示中,直接和协作流程有关系的是「未修改 (Unmodified) 」状态的文件,我们通常拉取远程协作者的 Git 项目数据时,拿到的数据是持久存在 Git 里,没有修改过的文件(数据),我们推送到远程服务器、分享给协作者的,一般也是提交之后,没有修改过的文件。「未跟踪 (Untracked) 」、「已修改 (Modified) 」和 Staged (可以理解为临时工作台上的)状态的文件,是 Git 用户在本地工作时,项目中文件所处的几个状态。

简单协作操作演示

下面我们使用一些具体的命令示例和示意图,来说明一些最简单的 Git 操作是怎样改写 Git 的哈希树的。以下面的图示一为例,图示从左往右为大致的时间先后顺序, main 为 Git 哈希树的主干,图中显示了 A 到 G 七个快照( Git 提交),有边连接起来的快照有直接的父子关系(比如 A 和 F 都是 E 的子快照),在快照 E 处,用户(让我们称其为张三)从主干建了一个 topic 分支,并先后做了 A B C 三次修改提交;同时,在主干上,其他协作者继续改动主干 main 上的数据,先后做了 F G 两次修改提交。

            A---B---C topic
           /
...---D---E---F---G---H main

图示一

rebase 协作方式

rebase 是用 Git 协作做项目开发最常见的方式之一,有文档把它翻译成「变基」,但我感觉好像剪枝嫁接更直观一些,只是从某棵树上剪下一根树枝,再接到同一棵树另一根树枝或主干上。用户可以使用 git rebase 目标分支 来源分支在一个命令里指定被剪的分支(来源分支)和嫁接到的分支(目的分支),这样可以无需考虑当前所在的活动分支是哪个。还可以先切换到源分支,再运行 git rebase 目标分支来剪枝嫁接。

rebase 操作还有其他细节的控制,可以交互式操作、可以在 rebase 的时候合并某些快照等等,可以在装有 Git 的机器上通过运行命令 man git-rebase 查询 Git 的用户手册 (man page) 以了解更多细节。

图示一的 Git 哈希树状态之后,假如张三执行 git rebase main topic ,若没有冲突,张三在 topic 分支的改动就可以顺利的嫁接回到主干上,但是这三个改动的一些数据(比如提交时间)会发生变化,并且其哈希值会被重新计算,但部分改动内容和之前 A B C 是一样的,所以下面图示二中,我把 A B C rebase 后的快照分别用 A1 B1 C1 表示, rebase 操作后,此项目的 Git 数据会变成下面图示二所示干净得好像只剩主干的树。

...---D---E---F---G---A1---B1---C1 main

图示二

「合并」协作方式

另一种方式是 merge ,一般被翻译为「合并」,仍然以上面图示一为初始状态,使用合并方式协作的时候,用户需要先切换到待并入的目标分支,再执行 git merge 源分支 。我们按照通常 Git 协作流程的管理,先切换到主干 main 分支,再运行 git merge topic ,把 topic 分支的改动合并到主干 main 上面,合并之后变成类似下面图示三的状态。

            A---B---C topic
           /         \
...---D---E---F---G---H main

图示三

如果张三在当前活动分支是 topic 分支时候,执行 git merge main ,会得到类似下面图示四的树,为了对齐快照节点间的边稍微改了一下边的长度。

            A---B---C---H1 topic
           /            |
...---D---E---F---------G main

图示四

实际上,如果读者仔细观察并亲身试验一下,就会发现其实无需考虑合并操作的来源和目的分支顺序,从树的结构来说,把 topic 分支合并到主干 main ,和把主干 main 合并到 topic 分支,得到的树是一样的,只是由于 Git 在合并时自动生成的提交信息和 git log 操作的显示使得两者看起来不一样而已,实际的「内容」变更部分是一样的。由于 Git 会记录合并时用户输入的信息,所以两种操作方式实际得到的数据确实是有差异的,也就是图示三的 H 和图示四的 H1 快照不会是一样的。

简单协作操作演示试验

以下是我实验得到的简略 git log --color --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%ci) %C(yellow)<%an>%Creset' --abbrev-commit -- 截图。

合并 topic 分支到主干 main

上方显示在 main 分支上执行 git merge topic 的结果,下面则是在 topic 分支上执行 git merge main 得到的结果。

合并 main 到 topic 分支

关于合并和 rebase 的优缺点,还可以访问 Atlassian 网站上的合并与变基文章做进一步了解。

扩展实验——一个 Git 快照(提交)的哈希值是怎样计算的

为了更好的理解 Git 快照的哈希值是怎样得到的,对英文以及运行终端命令没有排斥的读者,可以跟着 https://gist.github.com/masak/2415865 或者 https://blog.thoughtram.io/git/2014/11/18/the-anatomy-of-a-git-commit.html 一步一步的自己手动创建 Git 快照,以了解为何 Git 快照是不可变的,以及它为啥是依赖于时间的。