CS61B - Gitlet项目的总结与反思
本文最后更新于 2025年11月10日 晚上
前言
之前就听说过CS61B的gitlet的赫赫大名,也是抱着诚惶诚恐的心态,拖到了学完排序之前的所有部分才开始着手Gitlet的编写。事实证明我是对的,这个项目相当之综合,收益也很大,下面主要记录一下我的一些思考和踩过的坑,希望能帮助到各位。
这篇文章其实有点意识流,主要是自己的一些思考的记录,并不太适合作为一个完整的教程,不过,如果你看到你的思路和我认为的错误思路一致,可以及时止损。

易错点和小建议:
- 先通读一遍spec!虽然读完之后可能什么都不记得,但是大概留一个印象总是好的,这样可以方便你高屋建瓴地进行最开始的设计。
- 文件内容本身不需要序列化,Java 对象(如 Blob、Commit)需要序列化才能保存。
- 仔细读取Spec中关于序列化的部分,你需要意识到这些类中存其他对象的信息,都应该是存取他们对应的哈希,然后需要取用的时候再直接从文件系统中读取。
writeObject()是序列化用的,readObject()是反序列化用的。writeObject用来保存 Java 对象(序列化)writeContents用来保存纯文本或二进制内容- 对于
Commit和Blob的持久化,不能直接用sha1(this),因为这个不能做到内容一致<->哈希值一致,需要自己手写,而且更重要的是sha1()只支持对String和byte[]类求哈希。 Repository类不需要实例化!事实上大部分的类都不需要实例化,全都是从固定的文件路径中读取出来对象再进行操作再进行持久化的保存!所以Repo里面的所有方法和成员都应该是static的!- 保持类的设计的风格一致很重要
checkout和reset有一个坑:对于未跟踪文件可能被覆盖的风险的检查要perform this check before doing anything else!- 如果你要写根据哈希ID获取对象的getter方法,请注意硬编码路径的问题,意思就是在这个方法里面不应该是默认只能对本地路径求
Commit对象,不然写EC部分的时候会习惯性调用这个方法,然后出现NPE问题。当然,到时候直接不用getter方法而是直接读取也是可以(事实上我就是这样做的,但是感觉代码因此不够美观)。 - 对于failure的检查,仔细考虑好放在哪里合适。我开始没考虑好,在命令的方法本身和
Main类中均有判断,事实上有重复而且不美观,建议全部写到方法本身中,反而更好。 - 还是failure的检查,可以把类似的部分抽象成各种辅助方法。
- 可以把常用的一些方法写进
Utils类中,就比如在新建文件时的try-catch(写一个新建文件你就懂我什么意思了)。 - 在开始的设计的时候参考真实的git目录你可以注意到
.git/objects/文件夹下面实际上采用的拉链法,我建议你也在一开始就采用这样的方式,这会方便后面的前缀哈希索引。 - 涉及
"--"的命令,记得检查其正确性。 - 如果一个字符串可能为
null,而又想判断是否相等,为了避免出现NPE,可以不调用String.equals(),而是调用Objects.equals(...)。
开始的设计——模块划分
首先,非常建议各位去看一看这四个视频(如果Youtube链接挂了,B站有存档)

能够给你建立对于Commit,Branch,Blob,Staging Area这些概念的正确认识,非常非常重要!
我们的skeleton基本上只提供了Main Repository(以下简称为Repo)和Commit类,Main类不必多说,spec中也给了我们"You may, of course, write additional Java classes to support your project or remove our suggested classes if you'd like."的建议。因此本着我的“Java是一门纯粹的OOP”语言的浅显的认识,在参考了真实的.git文件夹的基础上,我的最终实现中包含这些类
- Main
- Repo
- Commit
- Blob
- Branches
- Head
- StagingArea
- Remote(对于EC有用)
而.gitlet文件夹的设计如下:
- .gitlet
- objects
- commits
- blobs
- HEAD
- branches
- staging_area
- remote这里再提醒一句,参考真实的git目录,
.git/objects/文件夹下面采用的拉链法,你可以在一开始就采用这样的设计,后面会极大提高你的gitlet的性能。
但是,但是,当我写到最后的时候,发现实际上只有Commit和Blob类是有用的,其余的反而给我带来了更多痛苦,完全可以整合到Repo类中。而这些类实际上可以依据此划分为两类,这点后面马上会提到。
关于每个类的设计
Commit
主要有这些成员
代码
/**
* The message of this Commit.
*/
private final String message;
/**
* The first father of this commit (ID)
*/
private final String parent;
/**
* The second father of this commit(ID), only appears when merge happens
*/
private final String secondParent;
/**
* storage blob. Key: fileName, Value: the SHA1 hash of a blob
*/
private final TreeMap<String, String> blobs;
/**
* The timestamp of this commit
*/
private Date timestamp;
/**
* the SHA1 hash of this object
*/重点看一下我的求哈希值的写法
public String getID() {
StringBuilder blobStr = new StringBuilder();
for (Map.Entry<String, String> entry : blobs.entrySet()) {
blobStr.append(entry.getKey() + entry.getValue());
}
String parStr = (parent == null) ? "" : parent;
String secondParStr = (secondParent == null) ? "" : secondParent;
String timeStampStr = Long.toString(timestamp.getTime());
return Utils.sha1(message, parStr, secondParStr, blobStr.toString(), timeStampStr);
}Blob
private final String filename;
private final byte[] content;求哈希值的写法
public String getID() {
return Utils.sha1(filename, content);
}Head
private String curBranch;Branches
private TreeMap<String, String> branches;StagingArea
/**
* filename -> blob id
*/
private TreeMap<String, String> stagedFiles;
/**
* files staged for removal
*/
private TreeSet<String> removedFiles;Remote
private TreeMap<String, String> remote;分成很多类的缺点
开始的思考
我开始是如何思考的呢?我觉得一个Commit包含了很多必要的信息,比如提交信息、两个父提交(的ID)、时间戳、Blobs的映射,显而易见我建立一个类是好的,同理我就延伸到剩下的所有需要持久化的结构中去了。
但是,仔细想想,你可以发现,Commit和Blob 的存在是有必要的,而Head Branches StagingArea的必要性并不高。
首先,他们是要反反复复用到的类,而Head Branches StagingArea是一次实例化之后就不会再有别的实例化的类,之后顶多是对这一个对象进行修改然后保存。其次,这些类的实质都是对一些东西的指针,而Head更为甚,完全就是一个String currentBranchName罢了。写成三个不同的类的唯一好处,可能就是方便持久化(因为直接写一个save(),把this写入比较方便)。
因此我认为对于后面三个类,正确的做法应该是在Repo的init方法中先建立这几个东西的文件,后面直接从里面读取即可。至于对应的持久化方法,在Repo类里面写诸如
private static void saveHead(String newBranchName) {
Utils.writeObject(HEAD_DIR, newBranchName);
}即可。
而且分成很多类还有这么几个缺点:
可能的getter方法的堆积
如果你把这些类里面的核心数据结构设置成了private(比如Branches类的核心数据结构就是private TreeMap<String, String> branches),那么你就需要写很多又臭又长的getter方法,而这些getter方法写在Repo类里面又会堆叠很长,比如说
public static commit getHeadCommit() {
return Utils.readObject(join(COMMITS_DIR, Branches.getBranches().getCommitID(Head.getHead().getCurrentBranch())), Commit.class);
}然后你又会去想着写getter的getter方法,缩减里面的逻辑,比如说上面的代码给Commit类写一个getCommitByID()方法,再给Branches类写一个getter方法,但是这又会带来新的问题:
逻辑冗余的语法糖
重复写很多getter方法,实际上是在给自己写一堆逻辑冗余的语法糖,在最后写的时候,你会忘记自己写过这个东西,然后又重复造很多轮子,从编码角度来说绝对是不美观的。
但是这个事情实际上是相当丑陋的,而且会给后面带来很多莫名其妙的问题,比如说
/** 获取这个分支上的最新提交的ID
*
* @param branch
*/
public String getCommitID(String branch) {
return branches.get(branch);
}
/** 获取当前分支上的最新提交
*
* @param branch
*/
public Commit getCommit(String branch) {
return Commit.getCommitByID(getCommitID(branch));
}造莫名其妙的轮子
比如说
public void put(String key, String value) {
branches.put(key, value);
save();
}不知道辅助方法该写到哪比较好
就以笔者编写过程的一个心路历程为例。
开始的时候,笔者把所有从文件系统中读取对象,然后返回对象本身的方法写在Repo类里面,比如
public static Branches getBranches() {
return Utils.readObject(BRANCHES_DIR, Branches.class);
}但是不知道为什么,我后面觉得,和什么类有关,就把这些方法写到对应的类中,于是我把这些方法迁移到了对应的类中。
但是实际上,写到后面你会发现,很多方法,你根本不知道他到底应该放到哪合适!比如说getHeadCommit,获取当前分支的最新提交,是放在Repo类里面好,还是放在Head类,还是放在Branches类?笔者最后放在了Branches类中,但我也没法说服自己为什么这样,总之非常牵强。这样也会导致你后面想调用对应的方法的时候,还不知道这是哪个类的方法。在我看来还不如全部写到Repo类中。
在保留几个类的划分的情况下缺点的解决方案
不过,上面的几个缺点其实也可以在多写类的前提下得以解决:
- 可以通过把成员都设置为
public来解决。 - 可以通过把所有辅助方法写到
Repo类里面来解决,因为成员都是public的了,所以可以直接访问。
不过,这件事情我基本上是快写完了才意识到,史山堆积,积重难返。所以我后面还是以分成很多类的结构来展开文章。
各个命令的实现
大概只讲讲思路
init
直接建立一系列的文件系统,然后新建头提交即可。注意failure的判断。
这里需要把上面所说的,只需要实例化一次的类的文件都新建好。对于我分成很多类的写法,我就写在了构造函数里面;而如果你没有写很多类,需要在init方法中new一个对象(对应的核心成员)出来再写入文件。
这里可能需要为Commit专门写一个无参的构造函数。
add
我的add和rm方法主要写在了StagingArea 类中,Repo类中就是一个简单的调用,不过核心思路不管怎么样都是不变的。
大致分为以下几步:
- 首先在CWD下搜索,是否存在这个文件
- 如果存在这个文件,那么到底要不要新建一个
Blob还得取决于这个文件到底有没有改动,而判断文件有没有被改动的依据:blobID是否一致(这里涉及到了开始的注意事项里面提到的,Commit和Blob类的哈希值函数的写法要求)。 - (这里到底要不要新建一个
Blob的逻辑,我写在了Blob类的构造函数中,如果在文件目录下join(DIR, sha1(filename, content)).exists(),那么什么都不做。) - 如果文件在HEAD commit中存在,且内容相同,什么都不做
- 否则,说明文件被修改或新增,进行一个
stagedFiles.put(filename, curBlobId); - 最后,还需要
removedFiles.remove(filename) - 记得维护持久化
rm
和add很像,核心逻辑如下:
- 如果文件当前处于待添加状态,则取消暂存该文件
- 如果文件被HEAD commit(当前提交)给追踪了,把他加入removal中,然后在文件系统中删除掉他
- 如果用户尚未删除该文件,则将其从工作目录中移除;除非该文件在当前提交中被跟踪,否则不删除该文件
- 如果文件既没有被存入暂存区也没有被HEAD COMMIT追踪,报错
- 最后记得维护持久化
commit
本质就是把暂存区的内容通过一个Commit类的构造函数构造出来,最后记得清空暂存区和更新Head指针。
大致是首先推入父节点的所有Blob,如果是"staged for removal"的就跳过;
然后推入暂存区的stagedFiles的所有Blob;
最后取时间戳、持久化、清空暂存区和更新Head指针。
log
实质上只需要求当前分支(一条链表)上的所有提交,直接一个循环即可。
获取最新头提交,一路向前回溯,打印每个Commit的信息。
这里打印Commit的信息可能会遇到问题,提示一下,主要是两点。第一点是判断这是不是一个merge结点,通过判断两个父提交是否都存在即可;第二点是地区的问题,在打印时间戳的时候需要设置
Locale.ENGLISH。
global-log
spec中提示了,Utils中有方法,直接用即可,这里我也懒得解释了,直接放出我的实现:
public static void globalLog() {
List<String> commitHistory = Utils.plainFilenamesIn(Commit.DIR);
if (commitHistory != null) {
for (String commitID : commitHistory) {
Commit.getCommitByID(commitID).printSingleLog();
}
}
}find
也是相当之简单,用Utils中同样的方法可以简单得出,直接给出我的实现
public static void find(String message) {
List<String> commitHistory = Utils.plainFilenamesIn(Commit.DIR);
boolean flag = false;
if (commitHistory != null) {
for (String commitID : commitHistory) {
if (Commit.getCommitByID(commitID).getMessage().equals(message)) {
flag = true;
System.out.println(commitID);
}
}
}
if (!flag) {
exitWithError("Found no commit with that message.");
}
}status
一个比较复杂的命令,可以分成两部分:打印分支和打印暂存区,而打印暂存区又可以分成四个部分:stagedFiles removedFiles Modifications Not Staged For Commit 和 Untracked files,姑且分成六个部分来讨论。
注意这些字符串都要按照字典序打印。
打印分支
没什么好说的,遍历分支的map即可,但是注意字符串按照字典序打印,同时当前分支的*不算在里面(不影响排序)。如果你看了spec里面说的
Be careful using a
HashMapwhen serializing! The order of things within theHashMapis non-deterministic. The solution is to use aTreeMapwhich will always have the same order.
那么直接遍历就可以得到字典序。
打印staged files
其实也很简单,无非就是遍历stagedFiles哈希就好了。不过这里可以注意一下Java中遍历Map的语法:
for (Map.Entry<String, String> entry : stagedFiles.entrySet()) {
String filename = entry.getKey();
System.out.println(filename);
}以及
for (String filename : stagedFiles.keySet()) {
System.out.println(filename);
}显然后者更美观。
打印removed files
和打印staged files几乎没有任何区别,略过
打印Modifications Not Staged For Commit
这一块比较复杂了,根据spec所说,满足以下四个条件之一就是"Modifications Not Staged For Commit":
- 被当前最新Commit跟踪,然后在CWD中被修改了,但是还没有add
- 已经被add了,但是add之后又改了
- 已经被add了,但是现在被删除了(CWD中已经不存在这个文件了)
- 被当前最新Commit跟踪,目前还没被add/rm过,但是现在被删除了(CWD中消失了)
所以可以先遍历被当前最新提交追踪的所有文件,解决情况1和4;再遍历当前被add的元素,解决情况2和3。
其中,判断文件有没有被修改,就是判断求出来的blobID是否一致,但是这里不能直接新建一个blob,因为新建blob的工作是在add方法中进行的。换一个角度考虑:status方法只是一个获取当前状态的“只读”方法,怎么能改变文件内容呢?
还有一个点,在两个循环中,如果发现当前文件已经被"staged for removal",也即removedFiles.contains(filename),则直接跳过!这个其实不难理解,都已经被推入removedFiles中了,怎么还能算"not staged for commit"的修改呢?
打印Untracked Files
用Utils中的方法取CWD中的所有文件名,如果这个文件既不在stagedFiles中存在,也不在头提交中存在,那么就打印文件名。
需要先对Utils方法取出来的所有文件名的List进行排序。
checkout
又是一个重头戏。分成三种Usage:
java gitlet.Main checkout -- [file name]java gitlet.Main checkout [commit id] -- [file name]java gitlet.Main checkout [branch name]
我们分三个部分来解决。
再提示一遍易错点,记得检查"--"的正确性!
checkout -- [file name]
大致就是取头提交中名为filename的文件,写入CWD或者覆盖CWD中的重名文件,不暂存新版本的文件。
这里按照说法走就行,不过我推荐往Utils中写这么一个辅助方法:
public static void overwriteFile(File file, byte[] content) {
try {
if (!file.exists()) {
file.createNewFile();
}
Utils.writeContents(file, content);
} catch (IOException e) {
Utils.exitWithError(e.toString());
}
}意思就是,如果文件不存在就新建并写入内容,否则覆写内容。这个逻辑后面会经常用到。
checkout [commit id] -- [file name]
这里有一个特殊点就是commit id支持前缀索引。这个地方spec去提示了一下看真实的.git目录,这下你应该懂为什么我开始建议实现拉链法索引了。这里我推荐写一个convertPrefixToCommitID的辅助方法。
如果单纯的暴力写法还是好写的。不过Spec里面貌似没有提到,如果前缀得到的哈希提交不唯一怎么办,这一点我自己写了一个failure退出,最后Gradescope的测试用例里面好像也没有涉及这样的情况。
回到命令本身,大致是获得指定targetIDprefix的提交里面的指定文件,写入/覆写入CWD。也没什么好说的,照着做就行。按理来说写到这里你应该已经堆积了很多方便自己的辅助方法了,这些诸如「判断文件是否在提交中存在」「根据文件名取Blob」「根据Blob取文件内容」都应该可以直接到位写出来。
checkout [branch name]
大致是分为这几个点:取这个branch的头提交里面的所有文件然后写入(覆盖)CWD,删除CWD中所有于当前分支被跟踪但是不存在于目标分支的文件,且最后将头指针指向branch name。除非签出分支是当前分支,否则暂存区将被清空
这里我们注意到,实质上是取了一个特定的提交,然后把整个工作区恢复到那个特定的提交时的状态。假设我们提前通读了spec,我们会意识到后面的reset命令也要用到这么一个类似的情况,因此我们可以把这一点抽象出来写一个辅助方法restoreCommitSnapshot。
这个的实现大致分为4步:
- 检查是否有未跟踪文件会被覆盖
- 写入/覆写目标Commit的所有文件
- 删除当前 commit 中存在但目标 commit 不存在的文件(这里可以调用
Utils.restrictedDelete方法)
其中第一步,在最开始的易错点中提到了,而且类似的检查后面也会反复用到,建议写成一个辅助方法,这里给出我的实现:
public static void checkUntrackedFiles(Commit target, Commit current) {
for (String filename : target.getBlobs().keySet()) {
File file = Utils.join(CWD, filename);
if (file.exists() && file.isFile() && !current.hasFile(filename)) {
Utils.exitWithError("There is an untracked file in the way; "
+ "delete it, or add and commit it first.");
}
}
}务必在执行2.和3.之前执行这个检查。
将工作区恢复之后,也不要忘记修改当前头指针指向的分支,清空暂存区。
branch
这里相比编码,更重要的在于概念理解。Spec中的解释很详细了,如果你搞懂了,那么其实就是一行的事情。
branches.put(branchName, headCommitID);这里引用一下spec,也是提醒读者注意检查之前的实现:
Make sure that the behavior of your
branch,checkout, andcommitmatch what we've described above. This is pretty core functionality of Gitlet that many other commands will depend upon. If any of this core functionality is broken, very many of our autograder tests won't work!
rm-branch
和上面一样,也是理解了概念就是一行的事情。当然,注意failure的处理。
branches.remove(branchName);reset
给定一个commit id(可能是前缀),将工作区恢复到那个时候的状态,同时修改当前头指针指向的提交,清空暂存区。
其实和checkout [branch name]几乎完全一样。直接复用代码就好了。甚至你完全可以把checkout [branch name]改写成先求出分支头提交,再调用reset方法的形式。
为什么我不把修改当前头指针指向的提交,清空暂存区写入restoreCommitSnapshot中呢?因为这两部分修改头指针的逻辑有区别。checkout [branch name]只需要将头指针指向给定的branch name就好了;而reset需要branches.put(curBranchName, targetCommitID);
merge
最复杂的命令,而且容易出很多bug,要有耐心阅读。
首先阅读Spec我们可以发现有一个非常重要的概念就是split point,那么我们自然会去考虑写一个求出两个提交的split point的辅助方法,具体思路如下:
- 假设我们有两个Commit,
c1和c2。首先,通过DFS求出c1的所有祖先的集合anc1,这里建议把递归改写成栈的形式,并且写一个返回Set<String>的辅助方法。 - 然后,再通过BFS遍历c2的所有祖先,只要在遍历的过程中得到了
anc1.contains(c2的某个祖先),那么直接返回这个提交。由于是BFS,因此这个过程保证我们可以得到最近的公共祖先。
然后,开始处理一系列的特殊情况:
- 首先是各种failure cases,按序按spec判断就好了
- 然后求出分割点的提交,接下来做一些别的判断
- 首先如果分割点提交(
split)就是给定的提交(given),输出"Given branch is an ancestor of the current branch."后退出。 - 然后是fast-forward的情况,也就是头指针远远落后于最新提交,而且头指针就指向分割点,那么就是将头指针一路前移,可以形象称为fast-forward。
这里务必务必提前检查是否会覆盖未跟踪文件,也就是调用
checkUntrackedFiles()!
然后,进入核心的merge环节。这里我们也需要理清楚逻辑,我的考虑是这样的:
上面所说的代码都是Repo类中的merge方法,接下来我要在Commit类中写另一个merge方法,这个merge方法的主要作用是依据spec的逻辑更新暂存区。
最后又回到Repo类中,调用构造函数得到新的Commit。
接下来我们先专注于Commit类中public static boolean merge(Commit head, Commit given, Commit split)的编写:
Spec中列出了8种情况,但是你可以通过这样的思考:
-
首先,获得头提交、指定提交、分割点提交的所有文件名的集合,然后遍历之,大致代码:
Set<String> filenames = new HashSet<>(); filenames.addAll(head.blobs.keySet()); filenames.addAll(given.blobs.keySet()); filenames.addAll(split.blobs.keySet()); -
在每一次遍历中,令
String H是头提交的blobID,G是给定提交的,S是分割点提交的; -
画一个表格,涉及
H、G、S的相等与不相等的情况,大致如下:检查顺序 条件(H, G, S 的关系) 代码中的逻辑 含义 (Action) 1 H == G(H 和 G 相同)if (Objects.equals(H, G))continue;(Do Nothing / Follow Head)2 H != G并且H == S(Head 未变, Given 改变了)if (Objects.equals(H, S))(分为两种情况) 2a (接上) ...并且 G != nullif (G != null)将指定的文件(根据文件名)改为 G的内容 (Take Given's Version)2b (接上) ...并且 G == nullelsestagingArea().rm(name);(Delete File)3 H != G并且H != S并且G == S(Head 改变了, Given 未变)if (Objects.equals(G, S))continue;(Do Nothing / Follow Head)4 H != G并且H != S并且G != S(所有相关方都不同)else(所有检查都失败)writeConflictFile(name, H, G);(Conflict) -
你会注意到事实上有很多情况,最后是遵循头提交,也就意味着你什么都不需要做
接下来再是又一个难点:如何处理冲突提交?其实spec中说的也很详细了,唯一的难点可能是在于处理可能的空文件。这里我的建议是:先初始化两个""字符串,然后如果对应的blobID != null,再读取其内容。最后按照spec要求组织好冲突文件的写法,写入即可,最后别忘了加入暂存区。
回到Repo类中,通过调用完了Commit.java中的merge方法,暂存区已经被正确处理好,接下来调用Commit的构造函数,然后更新HEAD,清空暂存区,根据是否有合并冲突输出"Encountered a merge conflict."即可。
这里有一点需要提及,因为Java不支持函数的默认参数,你可能需要给
Commit类再写一个构造函数,以支持其正确设置第二个父提交。
写到这里,恭喜你,1600分已经到手了!当然注意Style check
add-remote
写到这里,我先不直接讲命令怎么写,而是先建立对于Remote这个概念的正确理解
Remote本质和Branches很类似,其实就是一个TreeMap<String, String> remote,不过是由remote名指向具体的路径。
理解了这一点,很多Branches.java中的辅助方法可以直接拉过来用(当然,更好的是你参考了我前面的说法,不搞这么多类和private成员,这样辅助方法都不需要用)。
有一点非常重要,一般来说在Branches类中,只要涉及到改变提交树的,都会调用一下save()来持久化,但是save()的路径通常是依赖于CWD的,所以对于remote的部分,我们需要手动进行持久化,别忘了!
还有一点需要提及,spec中说到了
Have your program convert all the forward slashes into the path separator character (forward slash on Unix and backslash on Windows). Java helpfully defines the class variable
java.io.File.separatoras this character.
这里我们可以在Utils中写一个辅助方法:
public static String normalizePath(String remoteDir) {
return remoteDir.replace("/", File.separator);
}这样可以直接实现转换。
理解了概念之后其实这个命令实现起来很简单,对于路径进行转换之后直接put进去就好了。
rm-remote
也是非常简单,做好对应的failure检查之后,直接remote.remove(remoteName);就结束了。
push
又是一个相当复杂的命令。
首先,很自然的,通过从文件系统中读取,得到一大堆相关的便于操作的对象。
这里有几个点务必注意一下:
-
给定的
remote branch name就已经是包含了./.gitlet/的部分了,不需要自己额外加上,否则会变成./.gitlet/.gitlet/ -
如果你和我一样,对于这几个基本类的读取操作都使用了
Repo类中的CWD作为基础,那么在读取remote的各种对象的时候就会出现路径不一致的问题,解决方案是不要用辅助方法,直接用Utils中最原始的方法读取这些对象,比如File remoteBranchesDir = join(remoteDir, "branches"); Branches remoteBranches = readObject(remoteBranchesDir, Branches.class);
得到相关的Commit headCommit,Branches remoteBranches,Commit remoteHeadCommit等等之后,开始分析逻辑。
所谓的push操作,实际上的前提就是remoteHeadCommit是本地头提交headCommit的一个祖先,然后把这个提交树之后的所有部分全部复制到remote那里去。
首先判断前提,这里我们可以调用之前在merge部分写就的,得到给定提交的所有祖先集合的方法,就可以轻而易举地判断了。
然后是复制部分:这里我们面临一个核心问题:如何复制所有本地有而远程没有的 commit?
可以在
Utils中写一个public static void copyObject(File f1, File f2) {}方法,把文件f1复制到目录f2下。
一个常见的陷阱是只顺着第一个父提交一路回溯。这个方法是错误的,因为它会完全漏掉合并提交的第二条父分支的所有历史。
正确的方式仍然是使用DFS遍历:我们初始化一个Stack,并将本地的headCommit的ID压入栈。
然后开始栈非空循环:
- 弹出一个
commitID; - 检查
commitID是否等于remoteHeadID。如果是,说明远程仓库已经拥有了这个commit及其所有祖先,continue停止这条路径的搜索。 - 如果不是
remoteHeadID,说明这是远程仓库需要的新commit,则从本地仓库加载这个Commit和它指向的所有Blob,并将这些对象文件(Commit和Blob)复制到远程仓库对应的remoteCommitsDir和remoteBlobsDir目录中。 - 最后把当前
commitID的两个父亲压入栈中
最后,更新远程指针,手动完成持久化,比如:
writeObject(remoteBranchesDir, remoteBranches);fetch
事实上和push很像,或者说是相反着来的,这里的前提就是本地落后于远程,所以从远程复制文件到本地。逻辑照搬push就行,这里不再赘述了。但还是务必注意从远程仓库读取文件的逻辑与本地读取的区别。
pull
完全就是
public static void pull(String remoteName, String remoteBranchName) {
fetch(remoteName, remoteBranchName);
merge(remoteName + "/" + remoteBranchName);
}两个一起用的一个语法糖。
到这里,整个EC部分编写完成。
后记
本来因为学校的事情很多导致CS61B放置了一段时间(不过实际上也就四五天),但是最近又拾起来然后完成之,可能有点逃避现实的意思。自己被各种思绪裹挟,诸如「学校的课程质量之低」、以及「自己对于读研or就业的思考」,让我干脆找一个能够暂时抛开这些胡思乱想的地方去沉浸其中。不过事终究是会找上人的,想到这点还有点小崩溃;(
这个项目前前后后感觉可能写了30个小时,那个用python的集成测试我根本没用过)每次测试都是手打的,然后遇到很难的debug问题也时不时拷打过AI,自己水平还是有待提高。
最后感谢UCB提供如此精彩的课程