大概一年多前,我写了一片关于 Fossil 的工作流程的博文,文中吐槽了 Git 命令行的难用。现在回想起来,只是作为一名普通用户基于日常使用来批评 Git 的交互设计肯定是不够格的。于是最近我花了一些时间阅读了一些关于 Git 的文档,检出阅读了 Git 本身最早的几次提交代码,欲以探究 Git 的设计思想和实现方式。

不过首先,我们先聊一聊易用版的 Git — Gitless。

Gitless 工作流程

如果你先前学习了关于 Git 使用的一些概念,你可以先把他们放一放。Gitless 是构建于 Git 之上的,简化了 Git 使用的一些概念以及工作流程。

初始化代码库

这一步无非两种可能,在本地建立或者是从远端拷贝一份。

在本地建立需要先进入工作目录,然后执行:

git init

对应 Gitless 命令也不尽相同:

gl init

如果是要从远端拷贝一份下来,则是在工作目录的上层目录执行:

git clone <URL>

执行完成后当前目录下会出现一个和远端代码库同名的文件夹。通常这样就足够了。不过如果你想给这个文件夹取其他名称,你可以这么做:

git clone <URL> name

知道这些就可以应付九成的情况了。

对于 Git 来说,initclone 是两个不同的操作,而 Gitless 将它们合并成了一个命令。如果你想从远程代码库克隆一份下来,你需要先建一个工作文件夹,然后再进入这个文件夹 init

mkdir workspace
cd workspace
gl init <URL>

emm,看起来就是省了个关键字。我选择用 git clone,当网络不畅时本地目录也不会留下载残留。

提交

作为一个很严谨的项目,我希望第一次提交一个 LICENSE 文件。

你要做的第一步是跟踪需要提交的文件:

gl track LICENSE

对于同一个文件这个步骤只需要做一次。完成后就能进行提交:

gl commit

填完 commit 信息,保存退出就完成了我们的第一次提交。非常自然。

如果你用过 Git,大概会想到 add 命令。请忘了它。Gitless 去掉了暂存区的概念,用文件跟踪取代了手动添加暂存欲提交文件。这里我们用第二次提交来展示这一概念。

我们向 LICENSE 文件里填入反996许可证的文本。此时,LICENSE 文件就处于已更改的状态。我们可以通过 git status 命令查看工作目录下文件状态:

> gl status
On branch master, repo-directory //

Tracked files with modifications:
 ➜ these will be automatically considered for commit
 ➜ use gl untrack f if you don't want to track changes to file f
 ➜ if file f was committed before, use gl checkout f to discard local changes

   LICENSE

Untracked files:
 ➜ these won't be considered for commit
 ➜ use gl track f if you want to track changes to file f

   There are no untracked files to list

交互信息可以说是写得很详细了。此时,执行 gl commit 即是进行第二次提交。只要文件被跟踪,什么都不用操心。如果想取消跟踪,执行 gl untrack 即可。

git commit 相似,gl commit 也可以通过 -m 直接传入提交信息:

gl commit -m "添加许可证文件" 

使用 git add 的一个好处是可以控制提交的精细度 — 只提交被添加进暂存区的文件,但其实这种操作用 Gitless 也可以实现。只需在命令行中添加 -e (exclude) 及 -i (include) 标识符即可。如其字面意思,-e 后面跟随此次提交想要排除的文件名;-i 后面跟随此次提交想额外包含的文件名。

分支

你可以这么认为,如果要通过 Gitless 在任意一处提交上发起新提交,则必须通过分支来操作。这么做是为了简化逻辑,且同时切换分支也不再需要 搁置 这个概念了。

直接执行 gl branch 将显示代码库中所有的分支名并标星当前所处分支名。通常默认分支名为 master 你当然可以将他改成任何名字。当然如果不是为了政治正确通常没人会去这么做。

想要新建分支,只需执行:

gl branch -c 分支名

需要注意的是这样建立的分支是基于当前所处分支的最后一次提交。详细来说,如果你在完成一次提交后做了新的更改,此时通过 gl commit -c xxx 建立的分支内容不会包括那些提交后做的更改。也就是说,请在更改前就新建及切换分支。譬如说我们当前处于 master 分支,我们建立了一个名为 dev 的分支并想要切换到此分支上。只需:

gl switch dev

你可以随时在不同分支间切换。切换分支时,任何在当前分支的更改都会保存,也就是说,更改是随着分支走的,不需要在意未提交的更改。你可以随时通过 gl diff 看看自己改了些什么。如果你对自己的更改不满意,可以检出上次提交的文件覆盖这些更改:

gl checkout 文件名

当一个分支不再有用时,你可以删除它:

gl branch -d 分支名

请注意,你无法删除当前工作空间所处的分支。

所谓不再有用,其中一种可能是分支被合并。我们会在接下来的内容中提到它。

现在,我们先来考虑一个问题 — 如何检出某分支上的某一次提交?

使用 Gitless 时,任何检出操作都可以基于分支来进行。我能想到的最简单的方法如下:输入 gitk 打开 Git 自带的图形界面,找到并选择想要检出的那一项提交,鼠标副键选择添加分支,输入分支名创建后退出,最后 gl switch 分支名 即可。这么做避免了类似分离头指针这类有些令人害怕的操作。

合并分支

按照常规 Git 工作流程,我们有一个 master 主线分支和一个 dev 开发分支。现在,我们完成了 dev 分支的开发,想要将这些开发内容合并到 master 分支。Gitless 为此提供了两种操作方式:gl mergegl fuse (类似 git rebase) 。这里我们讲 merge 的用法。

第一步,我们需要切换到需要合并到的分支,这个例子中指 master:

gl switch master

切换前务必确认 dev 分支已完成最终提交,master 分支上也没有没提交的改动,即两分支都是干净的。然后我们就可以执行合并了:

gl merge dev

如果一切顺利,合并会自动完成。不过合并冲突常常不可避免。这时就需要手动处理。处理完后,你需要告诉 Gitless 你已经解决了合并冲突:

gl resolve 文件名

这是个防呆设计,Gitless 确认所有文件都解决了合并冲突后才会允许你提交合并后的更改:

gl commit

这样合并就完成了。之后你可以选择切换到 dev 分支继续开发:

gl switch dev

或者删除 dev 分支:

gl branch -d dev

标签等功能

你可以通过 gl -h 查看更多功能的使用,譬如打标签、查看提交历史、提交到远程服务器等等。

命令的设计和 gl branch 类似。以标签为例,列出所有标签可以使用:

gl tag

在当前提交上贴标签执行:

gl tag -c 标签名

删除标签当然是这样:

gl tag -d 标签名

远程代码库的增删也是如此。例如增加远程代码库:

gl remote -c 远程代码库名 <URL>

有趣的是,Gitless 通过 gl fuse 来从远程代码库拉取 (pull) 下来代码:

gl fuse 远程代码库名/master

同样的,你也可以把远端的分支当合并项:

gl merge 远程代码库名/master

可以说非常巧妙了。

更多命令细节,可以看 Gitless 的文档。

Git 工作原理

如果你把 Git 本身的代码 clone 下来,然后检出它的第一次提交。你会发现,哇,原来 Git 是一系列工具集合,有浓郁的 UNIX 风味。

我们先想象一下不使用版本控制软件该如何控制代码版本。最常见的做法当然是建压缩文件了。将整个工作文件夹打包压缩,然后取个名字“钦定项目代码终极最终版一(1).zip”。这么做既能压缩储存空间,又通过文件名给了压缩包内数据版本的提交版本说明。在正确取名的情况下,这一手动版本控制系统实际上已经达到预期了。不过,能否更进一步?每次工作文件夹打包压缩时,文件夹内部并不是所有文件都是更改过的,我们可以只将这些未更改的文件只储存一次。实现方法也很简单,我们可以首先把工作文件夹内所有文件单独压缩,然后分别给一个不重复的编号,然后记录工作文件夹里所有文件的信息清单,如果文件不变则重复使用上个编号即可。这种操作手动实现起来较为繁杂,但实际上可以交给计算机软件来做,这就是 Git 的核心储存部分

在 Git 的设计中,将这些要压缩保存的数据分为三种对象,数据对象 (blob object) 、树对象 (tree object) 和提交对象 (commit object) 。处理方法如下:首先将工作文件夹里所有选择要提交的文件内容分别单独用 zlib 压缩,然后使用 SHA-1 散列算法算出20字节长的散列值,以此为它们命名,即是数据对象;然后根据这些文件在文件夹中的结构,填写文件清单,其中包括目录结构、文件名、对应的压缩后散列值、文件权限及长度等信息,这个清单文件也通过 zlib 压缩然后算出散列值,即是树对象;最后填写提交内容说明、此次提交的树对象散列值和父节点等信息然后压缩算出散列值,即是提交对象。所有这些对象最后统统丢到当前工作文件夹的 .git/objects/ 文件夹下。因为早期文件系统中一个文件夹下的文件有不是很高的数量限制,所以保存时将保存对象文件名 (也就是文件散列值) 的第一字节作为文件夹名,后19位作为文件名保存。这一设计延续至今。

这一系列的操作核心在于散列算法,Git 当时选择了 SHA-1 。散列算法的用处在于只要文件有任何更改,通过它算出来的散列值就会不同,这样就可以保证保存的文件有唯一的编号。SHA-1 现已被证明有碰撞风险,Git 开发社区有转换到 SHA256 的想法,但是这种改变动摇到软件的根本了。SHA -1 虽然被证明不再安全,但是 Git 本身演化到现在,对文件的校验和长度检查等功能都很完善了,所以并没有太大风险。

好了,现在我们保存完所有对象了,如何将它们再原样取出来?

我们只需要知道最后一次提交的提交对象散列值就可以了。通过这个散列值,我们就可以从 objects 文件夹里找出这个提交对象文件本身,解压查阅文件内容就能知道这次提交的树对象散列值和上一次提交 (父节点) 的散列值。通过树对象的散列值找出对应的文件解压就又可以得知此次提交时所有文件的散列值,最后把这些文件按树文件还原即可。同时,因为提交对象里写明了父节点散列值,你可以顺着这个值一直往上一次提交找,最后画出整个项目的历史提交线。这不是链表么

顺着这个思路我们也可以自然地发现,所谓分支,其实就是指向某一次提交的指针罢了。

由此原理就可以明白 Git 的操作命令为什么会这么设计,例如 git add 其实是在准备数据对象和树对象,git commit 是在准备提交对象和移动指针等,而搁置操作是因为工作空间内的文件改动了却没提交,检出任何一次提交都会导致文件丢失。所有这些看起来繁琐的操作只是对 Git 的底层操作做一个简单的包装罢了,如果你顺着 Git 本身的提交历史往前看,你会发现好多指令都是用脚本语言将一些底层操作工具粘和起来使用而已,很有极客风范,同时吓坏吾等小朋友。

迷惑操作指南

Q: 我把 .git 文件夹删了咋办?

A: 没救了等死吧

Q:我能不能撤回一次本地提交?

A: 当然可以。原理上只是把指针往回移而已。你可以新建分支并切换到你想回到的那一次提交上,然后把想撤销的那一个分支直接删了,之后再在当前提交上重建分支并切换回去即可。听起来很复杂但不难理解。

Q:哇!确实撤回去了!能不能再撤回来?

A: 可以的,但不能单纯只使用 Gitless 来解决了。

因为我们上面提到的撤回操作事实上只是移动指针而已,所以那一次提交的数据仍然保留在 objects 文件夹中。我们要做的是找到那一次提交的散列值。幸运的是我们的操作都会有历史记录。执行:

git reflog

然后慢慢在日志中找那一次提交的短码,找到后执行:

git checkout 散列码

此时你已经切换到那一次提交上了。Git 会提醒你目前处于分离头指针状态。你需要做的是搞个指针指向这次提交新建分支:

git switch -c 分支名

剩下的操作你应该都会了。

初稿于 2021年05月18日

知识共享许可协议