Post

Gitlet实现及Style细节

详细讲解Gitlet中每一个命令背后的思路、用法以及实现,以及对一些代码样式的要求标准。

Design Document

Name:Moon.

CS61B的这个gitlet项目是一个实现近似于git的版本管理系统,本文注重于讲解个人完成这个项目时的总体思路以及某些格式要求问题。

.gitlet所包含的文件以及目录

objects(Dir)

这一个目录包含了众多以03,7a等编号首两个组成的目录,这是因为我们所用的Hash编码为SHA-1编码,将文件所有内容编码后获得了40为SHA-1编码,为减少文件查找的复杂度我们将其首两个字符提出并作为目录的名字,后38位作为文件本身的名字。

在原本的git中,应该具有tree,blob和commit三种对象同等的存在Objects目录下,但是在gitlet中简化到只在CWD下存储单纯的文件,所以无需tree对象,而为了高效率完成global-log方法,我选择在gitlet中将所有的commit对象都设置在同一个文件夹下面。

Commit(Dir)

用于存储commit对象,且直接将文件名设置为SHA-1哈希码。

gitlet同时可以通过较短id找到唯一的SHA-1 id,比如输入d3bd3udub3这样的非完整id可以在这个目录下找到具有此唯一前缀的SHA-1 id

refs(Dir)

refs文件夹用于存储Gitle仓库的引用,而这些引用都指向特定的提交,而这个refs目录之下的heads文件夹和remote文件夹分别对应着本地分支和远程分支的引用

heads(Dir)

heads对应的是本地的仓库中的所有分支,如果我们有一个master和一个develop两个文件在里面,则对应着两个分支为masterdevelop

需注意的是这存储的实际上也是一个Commit的SHA-1 id,只不过放在这里更具有结构性,通过HEAD定位到这里并读取文件内容得到SHA-1 id,则可以通过这个id找到Objects中存储的真实的Commit对象将其反序列化。

将此文件存在意义理解为一个指向对应提交版本的指针即可。

remotes(Dir)

对应各个远程仓库,其中的每一个文件夹对应一个远程仓库,再往深的文件则对应仓库的分支

INDEX(File)

以一个文件模拟暂存区,可以定义一个类并将其序列化存入该文件

该暂存区存储的对象主要有addFileMapremoveList两个数据结构,分别对应意义为”要添加的文件”以及”要删除的文件”,注意对CWD的删除操作应该在原本rm的时候就已经完成了,这里的删除主要是对curCm中对应文件映射的删除以创造新的targetCm

HEAD(File)

存一个路径表示当前分支是对应的什么,如refs/heads/master

Classes and Data Structures

Class 1 Commit类

将每一个提交都看作一个对象,这个对象应该包含

  1. parents —– 一个字符串链表,存储父节点的SHA-1 id,由于合并操作的存在,所以一个Commit可能具有两个父节点,因而采用链表存储
  2. id —– 表示该提交对象的hashcode编码
  3. message —– commit提交的时候带的类似于备注的信息
  4. timestamp —– 时间戳,除了initial提交其他都是当前时间
  5. fileMap —– 一个Map,映射到对应的Blob版本

Class 2 Blob类

每一个文件的不同版本都可以视为一个Blob,这个对象只包含idcontents两个成员,分别表示该类的hashcode内容字符串

这个Blob类较为特殊,它是SHA-1 id和文本内容的映射,也正是它映射的是用户真正要存储的文件,所以才能以这样的形式存储下来,其本身的存储还是以SHA-1编码为文件名存在Objects中的

Class 3 Stage类

包含一个addMap用于表示添加的文件及其对应的Blob和一个rmList用于表示删除的文件列表,因为一个文件可能对应多个Blob版本但是删除只有文件的对应所以无需用Map来存储

建议:设计一个addAndSave方法,这样在每次添加到暂存区或者从暂存区删除文件的时候就不会遗忘保存该文件并序列化在INDEX里面。

Class 4 Utils

这个类是Gitlet提供的工具类,也可以自己定义静态方法在这里以供项目使用

这里解释一下较为重要的方法用处以及其实现。

readObject

接受一个文件对象和一个对象的class文件,返回一个对象。即从给出的文件中反序列化一个给出的相关对象出来,并将其作为返回值返回,因此将该方法赋值给一个对象实例即可以得到从文件中读取原来存储的实例对象的功能。

####

Algorithms

每个算法实现的最开头都有一个checkGitlet,这是用于检测Gitlet是否成功初始化的,如若没有init则要报出错误信息。

Init realization

为了实现gitlet中的初始化操作,这个方法定义在仓库类中并是静态方法

Error Cases

如果.gitlet文件夹已经存在,则退出并显示相关信息

Success Cases

如果.gitlet文件夹不存在,则创建该文件夹并创建相关的目录(参照 .gitlet所包含的文件以及目录)。

创造完整个仓库文件夹之后需要创建第一次提交,即initial commit

在成功创造了第一次提交之后需要同步把HEAD更新到该提交,注意这里的更新有两个,一个是更新HEAD文件,另一个是更新master文件,即把当前分支的头指针更新的同时把头指针指向的具体分支也更新

Add realization

Blob类型添加到Objects文件夹下,就像rm的时候会直接从CWD中删掉,不同的是rm操作并不会删除对应的Blob文件

Error Cases

如果要添加的文件在此工作目录不存在,则直接退出并报出错误信息。

Success Cases

  1. 上一次的commit有这次提交的相同文件名,首先判断暂存区是否删除该同名文件,即查看rmList,如果含有该文件,则从rmList中移除这个文件并保存暂存区;如果rmList中没有,则其次按照对应的contents的SHA-1编码是否一致进行分类,若一致直接exit即可,若不一致则需要把该映射再put进去 —— 因为java中相同的key对应后put进球的value所以直接put即可
  2. 上一次的commit没有这次提交相同的文件名,则只需要进行单纯的映射put即可

Rm realization

从CWD中删除文件,或者从暂存区中已经添加并暂存的文件中删除

Error Cases

如果既没有在暂存区中有该文件,也没有在CWD中有该文件,则没有文件可供删除,报出错误信息。

Success Cases

  1. 已经暂存以供提交,则将其从暂存区移出,但这种情况无需在CWD中删除,因为此时属于Untracked File
  2. 在当前提交中存在该文件,则将其从curCm的映射中移出,并且如果用户并未从CWD中删除该文件,则需要将其删除。

Commit realization

虽然理论上是要讨论执行commit命令时候的实现,但这里我想顺便将merge操作时自动提交的实现也提一嘴,那么先讨论正常的提交吧。

Common Commit

Error Cases
  1. 输入的信息为空或者没有输入信息
  2. 暂存区为空,即尚未有需要提交的文件,这里的提交即指添加的也指删除的,只要有更改都算在内。
Success Cases

按照暂存区存的内容对curCm进行更改,即按照addMap和rmList的内容对提交进行更改并创建新的提交。

Merge Commit

Log realization

因为是对当前的提交日志进行查看,所以不存在什么错误情况,便直接写实现了

在gitlet中,log的情况被简化了,即从当前头提交一直往父节点遍历直到初始提交,如果遇到merge的节点,则从第一个父节点即主动合并的分支往前遍历,而这正是git中git log --first-parent实现的内容。

这里直接用System.out.println(cm)来打印输出,我们知道直接打印类会对应打印其toString()方法,所以toString()方法也需要按照有一个还是两个parent进行分类格式化。

Global-log realization

这个算法的实现与log并无大体上的区别,只是这个算法是将Commit目录下的所有文件都遍历一遍,故而用到项目提供的plainFilenamesIn()方法,该方法将目标目录展开并将其下的所有文件而非子目录提出存为链表

Find realization

命令给出一个Message信息,这个算法要实现的即是遍历Commits下所有提交并找到具有此信息的提交,打印出其id

实现和失败情况都较为简单,便不赘述了。

实现 :用plainFilenamesIn()方法。

失败情况 :如果找不到则打印对应信息。

Status realization

这也只是打印当前项目的总体状态,所以不存在什么错误情况,故直接给出实现

  • Branches :找到HEADS_DIR目录并遍历,如果遇到分支名同时是当前分支则在前面加上*,其他的逐行列出即可。
  • Stagesd Files :这里不是指暂存区的意思,而是暂存以添加的部分,即通过add添加到暂存区的。
  • Removed Files :即暂存以删除的部分。
  • Modifications Not Staged For Commit
  • Untracked Files

Checkout realization

Error Cases

  1. 对于前两种情况而言,有可能目标SHA-1 id不存在,也可能存在但是其 fileMap下不存在目标File
  2. 对于第三种情况,有可能切换的目标分支不存在,亦可能目标分支与当前分支是同一个分支,无需切换
  3. 对于第三种情况,写在checkUntrackedOverwritten()里的即检查是否有未被跟踪的文件被重写,未被跟踪即同时不存在暂存区也不在当前提交中 —— 因为Gitlet只是对跟踪的文件进行版本控制,未被跟踪的理论上应该忽略,而如果重写了相当于把本该忽略的牵扯了进来,因此需要报错并让用户进行修改。

Success Cases

分情况讨论有三种情况</Br>

  1. 参数字符串数组长度为3 : 这种情况是将head commit的一个指定文件到CWD下并且将其替换
  2. 参数字符串数组长度为4 : 这种是指定某一id的提交的指定文件并将其替换现有的CWD下的文件

上述两种直接调用自己编写的fileCheckout()方法即可。

  1. 参数字符串数组长度为2 : 切换到指定分支,即用指定分支head commit替换CWD,并覆盖当前的文件版本,再更新HEAD_FILE将其指向目标分支。这里再调用commitCheckout()方法将整个提交更改。

为了观看直观,在此给出commitCheckout()的实现。

commitCheckout() :对于当前分支有而目标分支没有的,将其从CWD中删除;对于当前分支和目标分支都有的,用fileCheckout()方法更新文件;对于当前分支没有而目标分支有的,用fileCheckout()方法更新文件,在这里此方法的作用是创建目标文件

Branch realization

这个命令用于创建新的分支,可以理解为在当前提交的位置增加一个指针,其名自定义,后面可以通过checkout到该分支以增加提交,此行为理解为移动分支指针并延长分支链

Error Cases

如果要创建的分支已经存在,则不可覆盖,退出当前进程。

Success Cases

HEADS_DIR下创建新的分支文件,并以命令中给出的branchName命名该文件。

:由于checkout的时候也会更新头指针,所以这里直接用getBranchFile()方法来获得要写入当前分支文件的Commit版本。

Rm-branch realization

有了branch的基础相对的删除算法就很简单了,只需要排除掉要删除的分支不存在要删除的分支即是当前分支两种错误情况,即可删除对于branch文件,实现该算法。

Reset realization

就目前看来,似乎与checkout的更改到某一特定提交SHA-1 id没什么区别,这里的reset正对应真正的git的git reset --hard

错误情况 :给出的SHA-1 id不存在,因而要退出。

实现 :与checkout()一样,要调用checkUntrackedOverwritten(),再调用commitCheckout(),并将暂存区情况并保存且将该分支的分支文件内容改写为指定CommitSHA-1 id

Merge realization

终于到了最后的合并算法,这一步后我们的提交集终于从一个简单的序列发展成一棵树之后再最终发展成为了一张有向无环图

这个算法长度极长,但由于Gradescope的样式检查要求长度不能超过80行,故只能编写一个丑陋的方法强行拆成两个方法减少行数,有空优化掉

Error Cases

  1. 判断暂存区是否清空
  2. 判断要合并的该分支是否存在
  3. 判断要合并的分支与当前分支对应的Commit是否相同,相同则无需合并
  4. 判断是否有要被合并的文件未被跟踪,同理于之前的checkout操作

Success Cases

  1. 当分裂点为当前分支的头,这个时候目标分支的版本在基于当前分支的后面,所以会直接checkout到目标分支并且输出对应信息
  2. 当分裂点为目标分支的头,则当前分支的版本在目标分支的后面,即什么都不做并且输出对应信息

ps:在这之后的情况都是把合并后的版本存入暂存区等待commit,这与git的逻辑一致,而上述两个情况都无需进行什么更改,因此不需要存入暂存区;上述两种不涉及产生新的Commit版本所以不需要暂存,而下面的几种合理的合并方式都会对CWD进行更改并且最后合成新的Commit版本,所以需要暂存以供Commit

  1. 在目标分支中修改过而没有在当前分支中修改过的文件,应该暂存为目标分支中的版本。
  2. 在目标分支中没有修改而在当前分支修改过的文件,则应保留为当前分支的版本。
  3. 如果在两个分支中一个文件被相同的修改或者都被删除,则在新的提交中仍然是这样,如果都被删除了那么哪怕CWD中有也是未被跟踪状态,不放在新提交中
  4. 在分裂点存在且当前分支未修改且目标分支中不存在的都应该删除
  5. 在分裂点存在且目标分支未修改且当前分支不存在的都应该保持不存在
  6. 在当前分支和目标分支都进行不同修改的会引起冲突

Style

文本文件在POSIX标准中在最后一行需要换行符,即空出最后一行,这是因为许多工具和编译器都期望文本文件是这样格式化的,这在本Proj中也有Style上的要求

对于<>,()这些括号中,分割参数之间一般以, 即逗号再加空格的形式

对于命名,有camelCase的标准要求,即不能以下划线开头,且要以小写字母开头,对于静态变量,要全用大写字母且以下划线分隔。

同一行不能有过多字符,一般不可超过100个,可以进行以下改进 方法调用链拆分

1
2
3
4
5
6
7
// 原始代码(超出长度限制)
String result = someObject.someMethod(arg1, arg2).anotherMethod(arg3, arg4).finalMethod();

// 拆分后
String result = someObject.someMethod(arg1, arg2)
                          .anotherMethod(arg3, arg4)
                          .finalMethod();

长条件语句拆分

1
2
3
4
5
6
7
8
9
10
11
// 原始代码(超出长度限制)
if (condition1 && condition2 && condition3 && condition4 && condition5) {
    doSomething();
}

// 拆分后
if (condition1 && condition2 
        && condition3 && condition4 
        && condition5) {
    doSomething();
}

字符串拼接换行

1
2
3
4
5
6
// 原始代码(超出长度限制)
String message = "This is a very long message that exceeds the character limit for a single line.";

// 拆分后
String message = "This is a very long message that " +
                 "exceeds the character limit for a single line.";

多参数调用拆分

1
2
3
4
5
6
7
// 原始代码(超出长度限制)
someMethod(param1, param2, param3, param4, param5, param6, param7);

// 拆分后
someMethod(param1, param2, param3, 
           param4, param5, param6, 
           param7);

if,while,for等跟()的语句都要与()之间间隔一个空格。

This post is licensed under CC BY 4.0 by the author.