Gitlet 项目复盘

声明

本博客需要的前置知识:数据结构 - 链表,Linux 命令行基本知识

本篇博客是对于 UCB CS61BL 课程中的 Project 2 进行复盘和反思。

根据课程的政策,我不会在本篇博客中展示我项目中的任何代码以及底层实现,此博客仅仅从项目的高层设计思想和相关知识点展开。

本内容的大部分知识仅来源于项目,而并未对实际的 Git 软件源代码进行解析,所以在一些内容上可能会与 Git 有所不同,可以说,这个项目是一个简化版的 Git, 通过实现 Git 的部分功能,从而得到对于数据结构的进一步理解。

这个项目是做什么的?

Git 是什么

Git (/ɡɪt/)is a distributed version control systemthat tracks versions of files. It is often used to control source code by programmers collaboratively developing software.

Git 是一个版本控制软件,例如平常使用的一些 App,可以看到上面有版本号,例如我手机上的微信版本号是 Version 8.0.50,随着软件不断的进行开发迭代,软件开发者使用版本号来区分不同版本的同一软件。

而 Git 的功能比 App 平时使用的这些版本号要多得多。每当开发者对于源代码进行修改并提交,那么这个提交将会永久的被保存,并且可以在任意的时候恢复到当前版本。如果说 App 的版本号是对于开发者打包好发布的内容进行标记,那么 Git 会追踪每一个被添加的文件,对于任何修改都记录下来。

举个例子,我们让 git 追踪一个如下名字叫做 myFile.txt 的文件。

1
2
// File name: myFile.txt
This is my file.

那么如果让 Git 追踪这个文件,那么当我把文件修改成如下:

1
2
// File name: myFile.txt
This is your file.

虽然在文件目录中,因为两个文件的文件名是一样的,所以看起来这两个文件是一致的,但是对于 Git 来说,它能察觉到文件内容被修改了(Git 是如何察觉到文件被修改的?会在后文进行阐述)。如果我们让 Git 存储这一次修改,那么我们就可以在后续恢复的时候选择回复到当前版本,举个例子。

1
2
// File name: myFile.txt
I do not know whose file is here.

看起来我们的开发人员犯了些错误,他不小心删去了文件的内容,这个时候我们就可以让 Git 来恢复,使用一些奇特的指令:

1
2
// File name: myFile.txt
This is your file.

这样我们就挽救了一次开发过程中的损失。

Git 的基本操作

在这一节,我们仔细的刨析一下 Git 有哪些指令,这些指令又分别是做什么的,毕竟只有知道了自己的需求,我们才能明确 Gitlet 要怎么样实现。

命令行的演示我会通过在 Windows 下的 Bash 进行演示,在这里我就直接使用正在写的这篇文章所在的路径进行演示,先通过 cd 命令到达目标路径。

1
2
3
4
5
***@***** MINGW64 ~
$ cd ~/Desktop/Blog_CS61BL_Proj2_Review

***@***** MINGW64 ~/Desktop/Blog_CS61BL_Proj2_Review
$

然后我们需要使用 git init 在当前路径下创建我们用来管理版本的仓库。

1
2
3
4
5
6
***@***** MINGW64 ~/Desktop/Blog_CS61BL_Proj2_Review
$ git init
Initialized empty Git repository in C:/Users/***/Desktop/Blog_CS61BL_Proj2_Review/.git/

***@***** MINGW64 ~/Desktop/Blog_CS61BL_Proj2_Review (main)
$

这个时候我们观察一下文件资源管理器,会发现多出来了一个 .git 文件夹

git_init

这个文件夹就是所有存储当前路径下所有版本的仓库。

如何让 Git 开始追踪我们想要追踪的文件呢,我们使用 git addgit remove 等指令来完成,首先我们先来看一个文件对于 Git 可能存在的状态。

file-status

当我们使用 git add 指令,一个文件就被我们推到了台前,也就是 Staged 。这些文件就好像被激活了一样,Git 可以很轻松的追踪这些文件。

接着通过使用 git commit ,Git 会检查所有在台前的文件,将它们全都存储到我们的仓库中,这个时候仓库中就已经有了我们当前文件的副本(实际在 Git 中不会这么操作,因为这会占用大量的空间,为了简化理解于实现,我们在这这样描述)。每一次 Commit 就如同交给仓库管理员当前工作区的快照,同时将这些文件存储到仓库中,当想要恢复当前的区域到某个过去时刻的状态时,我们只需要向仓库管理员请求这个时候的快照,根据快照从仓库中取出对应时刻的工作区文件。

在 Commit 之后,所有在台上的文件会被设置为 Unmodified 的状态。之所以不将其设置成 Untracked 状态,是因为只要我们 Add 文件一次,那么这个文件会一直被追踪,直到我们告诉 Git 不需要对这个文件进一步追踪了,也就是使用 git rm

这个时候如果我们将这个 Unmodified 文件再次进行更改,那么 Git 会发现这个文件被修改了。但这并不表明因为 Git 在追踪这个文件,就可以直接使用 git commit ,我们依旧需要使用 git add 将文件再次置于台前。

以下是将当前的这个 .md 文件 commit 的过程:

1
2
3
4
5
6
7
8
***@***** MINGW64 ~/Desktop/Blog_CS61BL_Proj2_Review (main)
$ git add "Gitlet 项目复盘.md"

***@***** MINGW64 ~/Desktop/Blog_CS61BL_Proj2_Review (main)
$ git commit -m "Add blog .md file"
[main (root-commit) 35cf9df] Add blog .md file
1 file changed, 125 insertions(+)
create mode 100644 "Gitlet \351\241\271\347\233\256\345\244\215\347\233\230.md"

至于查看 Git 的工作状态,可以使用 git status 或是 git log 。两者的区别在于,git status 聚焦于文件层面,比如是否有文件因为被更改而进入了 modified 的状态,所以比较适合用来在 Commit 之前检查是否有文件按照预期进行修改。git log 则聚焦于 Commit 上,会显示所有之前提交的 Commit,就像如下。

1
2
3
4
5
6
7
***@***** MINGW64 ~/Desktop/Blog_CS61BL_Proj2_Review (main)
$ git log
commit 35cf9df4abd2a1ca30b32df6063369999a1db8bc (HEAD -> main)
Author: ***
Date: Mon Aug 12 18:07:02 2024 +0800

Add blog .md file

此时,如果使用 git restore ,就可以恢复到当前版本。

1
2
***@***** MINGW64 ~/Desktop/Blog_CS61BL_Proj2_Review (main)
$ git restore fileName --source=35cf9df4abd2a1ca30b32df6063369999a1db8bc

为什么使用 Git

在了解了 Git 的基本工作原理后,就可以发现这种工作模式的一些优点:

永远不会丢失历史版本

Git 将所有的历史信息都存储到了 .git 路径下,只要不删除 .git 路径,那么随时都可以恢复到任意 Commit 时的状态, 同时如果通过 git pushgit pull 将代码上传到特定的 git 服务器或是从 git 服务器下载(这里并不只是仅仅指 github 这一个平台,也有可能是基于企业或个人自己搭建的代码托管平台),那么通过异地备份,进一步提高了数据的安全性。

时刻追踪

在日常编辑其他文件时,虽然也会在保存时询问是否要保存,但是对于版本修改的限制相比于 Git 要小很多,比如在 Git 中,如果对文件进行了修改,却并没有将其 add ,那么就无法提交 commit 。

多端同步

通过 git pushgit pull ,可以实现多个人同时访问同一个仓库,进行高效协作的工作流,快速推进工作的进度。同时 Git 的一些其他特性,诸如 branchmerge ,可以同步推进不同版本。

底层的架构设计

版本控制的几种形式

版本的推进是什么样子的呢,下图是最简单的一种版本控制方式。

链表版本流程图

从最初的 1.0 版本不断的推进,这种结构最适合的存储方式就是链表,只需要在类中额外使用一个变量作为指针,就可以存储所有的版本。

树状版本流程图

在上图的基础上,设计师认为在 2.0 版本时候需要单独开辟一个版本作为面向另一个客户群的版本,所有在 2.0 的基础上开发者开发了 3.0b 版本和 3.1b 版本,我们可以假设这些版本是面向企业的,那么这个时候我们用来存储的方式应该是一个树,不过使用指针的思想依旧可以保留。

这种结构在 Git 中叫做Branch 分支,分支保证了同一个历史版本的基础上可以拥有不同的后续版本。

图状版本流程图

最后我们会引入 Merge,Merge 保证了我们可以将不同版本的工作流进行融合,Git 此时会查找两个分支中被修改的部分,并将这些修改同时应用到新的提交上。

举个例子,假设上图所示的开发工作流是为了开发一个图书管理系统,2.0 版本中,我们有了基本的图书检索功能, 2.1a 版本实现了按照图书类别进行检索的功能,2.1b 则聚焦于按照图书借阅次数给出图书的热门指数,2.2b 在此基础上为使用该系统的用户推荐热门指数高的图书。那么使用 Merge 构建的 3.0 版本应该同时具有按照图书类别进行检索的功能以及与热门指数相关的功能,因为 Merge 融合了自 2.0 版本以来开发者对于图书管理系统的修改。

一个问题是,在以上的例子,如果 a 版本和 b 版本对同一功能进行修改,会发生什么?在 Git 中,当视图 Merge 时,它会提示你两个版本发生了冲突,并且阻止你,直到代码被修改至不冲突。

至于这种结构想要存储起来,那么图理所应当是最合适的结构。

存储数据结构

那么具体到实际代码,要如何进行实现呢?

实际上的Git实现会更加复杂,通过把一个commit中每个文件的各个部分划分为一个个Blobs,如下图所示,这样在blobs和commits之间通过指针进行映射,就可以在尽可能小的体积下存储更多的版本内容,不需要每一个文件都要重复存储很多遍。

同时,每一个commits中还会附带相应的元信息,包括时间,对应的Hash值等等。

commits-and-blobs

对象与文件

如何进行文件与文件之间的识别

但是具体到细节,如何区分文件是否被修改了?这里就会用到散列函数

A hash function is any function that can be used to map data of arbitrary size to fixed-size values, though there are some hash functions that support variable length output.The values returned by a hash function are called hash values, hash codes, hash digests, digests, or simply hashes.The values are usually used to index a fixed-size table called a hash table. Use of a hash function to index a hash table is called hashing or scatter storage addressing.
哈希函数是任何可以用来将任意大小数据映射到固定大小值的函数,尽管也有一些哈希函数支持可变长度输出。哈希函数返回的值被称为哈希值、哈希码、哈希摘要、摘要,或简称哈希。 这些值通常用于索引一个固定大小的哈希表,称为哈希表。使用哈希函数索引哈希表称为哈希或散点存储寻址。

简单来说,Hash函数会输出一个值,对于不同的输入,Hash函数会尽可能确保输出不同,通过这种方法,通过计算一个文件的Hash值,就可以很方便的得到这个文件是否进行了修改。

作为题外话,Hash函数并不保证输出不同,这叫做碰撞,及两个输入对应了同一个输出,但是这种情况极其少见,基本上可以忽略这种可能性。

细节拾遗

命令行如何操控程序的

Git 和常用的拥有用户交互界面的软件不同,直接使用命令行进行交互。在刚上手的时候可能会觉得较为复杂,但是比 GUI 实际上提供了更多的交互选项。

假设有如下的一条指令:

1
$ git commit -m "Add blog .md file"

Git 代表了调用的软件,在 Windows 上是通过遍历系统环境变量得到的。commit 代表了调用软件的实际功能,-m 和 “Add blog .md file” 都是具体的参数。交代了为了实现这个功能,程序还需要了解的其他信息。

文件的路径控制

首先,当一个程序对于系统进行操作的时候,往往分为绝对路径和相对路径。绝对路径的比对对象是使用挂载的文件系统根目录,在正常的使用中会因为文件的位置发生改变而导致要更改程序的情况。

与之相比,相对路径就不会有这样的情况,运行的根路径往往是当前所在的位置,只用提供当前位置如何访问目标文件的方式即可。