完结撒花
今天我终于把UC Berkeley CS61B中的第二个项目Gitlet写完了,总共花费17个小时(不包含读文档的时间)。我也不知道是算快?还是慢,反正除了merge命令我都写的挺爽的。

这个项目旨在让学生用 Java 完成一个小型版的 Git 版本管理系统,包含基本的add,commit,status,log,merge等功能,但也对真实的 Git 的功能做了一些简化。其中远程仓库的内容是可选的加分项,所以我并没有写
btw,这应该是我成年前发的最后一个文章、写的最后一个项目。祝我自己18岁生日快乐。
一些提交记录:
1 | carkree@MacBook-Air skeleton-sp21 % git log --pretty=format:"%h %ad | %s" --date=local |
项目总结
开始前的建议
学完 20. Heaps and PQs 就可以开始正式做这个项目了,可以完成除了 merge 命令以外的所有内容。
哪怕还没有学到 20. Heaps and PQs,我也建议可以提前开始读spec。因为这个的spec真的很长,可以闲得没事干的时候就去看一眼。spec中也额外要求了几个intro和lab,记得完成他们。
学完 22. Graph Traversals and Implementations 就可以做 merge 命令了
在做项目的过程中,先自己想,但是超过30分钟还想不出来的话,我认为是可以用AI辅助的,但是不要让它直接生成代码,而是让它为你解释spec、交流思路。如果是因为没能看懂需求而一直卡着,那我觉得浪费这个时间很没有意义。
我的基础结构与思路
项目需要在工作文件夹创建一个.gitlet目录,结构如下:
1 | .gitlet/ |
Commit 的实现
- 每个 commit 都是一个
Commit类的实例。 - 每次执行
commit命令时,都会创建一个新的Commit对象。 Commit类内部有一个parent成员变量,用于存储上一个提交的 ID,由此形成一种链式结构。Commit类需要implements Serializable,这样才能将对象序列化后写入文件,并存放到.gitlet/objects/commit目录下。- 后续实现
merge后,一个 commit 可能同时拥有两个父提交,因此需要再增加一个“父提交“成员变量,此时整体结构将从链表演变为一个有向无环图。
重要概念:什么是 Blob?
- 在 Gitlet 中,每个文件都会以 blob 的形式存储。
- Blob 保存文件的内容本身,不包含文件名

Commit类负责将“文件名”和“文件内容”关联起来。Commit类中有一个成员变量,通常以HashMap<String, String>的形式存储:- 键(Key):文件名
- 值(Value):该文件对应 blob 的 ID
分支(Branch)
- 所有分支保存在
refs/heads目录下,每个分支都对应着一个文件。 - 默认的初始分支是
master - 该目录下每个文件中存储着对应分支当前指向的 commit ID。
- 因此,分支本质上可以理解为一个“可移动的提交指针”,也就是某一分支的头指针。每次做新的commit,就更新这个头指针,让对应的文件中存放新的commit ID。
HEAD
- HEAD 文件用于记录当前所在的分支。
- 该文件中存储当前分支的路径,例如:“
refs/heads/master“
暂存区(Stage)
- Gitlet 中有两个暂存区:
- 添加暂存区(add stage)
- 删除暂存区(remove stage)
- 暂存区本质上可以看作一个
HashMap:- 键(Key):文件名
- 值(Value):对应 blob 的 ID
- 执行commit命令时,则要根据暂存区哈希表来更新commit哈希表
除此以外,代码中还需要用到项目提供的 Utils 类,其中封装了许多实用的方法,例如将内容写入文件、将对象序列化并写入文件,以及获取当前目录下的文件列表等操作。
在完成整个项目的过程中,也需要适当地提炼一些私有辅助方法,这不仅能减少重复代码,也方便后续功能的实现和维护。
Merge命令的实现
merge 是最为复杂的一个命令,光是错误情况就有很多种。
在执行 merge 后,所有的 commit 将不再是树状结构,而是组成一个有向无环图(DAG),因此需要用到图遍历(graph traversal)的相关思路。其中,最重要的是找到 split point(分裂点),即分支开始分叉的位置。split point 一定是两条分支最新的共同祖先。
这里我使用了 BFS(广度优先搜索)。首先沿着目标分支进行搜索,将所有 commit ID 存储到一个 HashSet 中;接着,再搜索当前分支,直到找到第一个出现在该 HashSet 中的提交,它就是 split point。
接下来,需要比较当前分支最新提交、目标分支最新提交以及 split point 三者之间的关系,以决定是执行 checkout、rm,还是处理冲突。我首先遍历了这三个提交,获得了一个存储所有文件名的 HashSet。随后,对该 HashSet 进行遍历,并在循环中分别使用 currChanged、targetChanged 和 sameResults 三个变量记录三者之间的变更关系。
剩下的就是根据这些变更关系进行判断。Spec 中对此写得非常详细(但是就是快给我看晕了)。如果两条分支的处理方式不同,例如一个修改一个删除,或是修改的内容不一样,就属于冲突。 基本思路如下:
1 | - 如果当前分支和分裂点没有不同(`currChanged` 为假) |
结语
这个项目还是很大程度上提升了我的能力的,除Main的错误情况外,全程古法编程。我写项目的经历本身就不是很多,都是在搞一些乱七八糟的杂东西,所以我从这之中收获很大。这也是我用Java写过最大的东西了,在上CS61B之前我只会python。
未来,可能还会把下面的课都学完,但是Project3我不是很确定有没有时间做了。
本文使用CC BY-NC-SA 4.0协议进行许可