CS61B GITLET PROJECT

简化版Git,深入探索Git的底层结构

Project 2 Getting Started(Lab6)

Preperation

首先使用git submodule update --init --recursive命令将21sp的library更新,再一并复制过来。记得再当前操作系统中设置REPO_DIR环境变量为所有projA、HW的根目录。切记要注意看文档, 以及其中给出的设置来实现相应的功能, 最后才发现FAQ也给了非常好的提示,另外比如说在我的机器上使用python而不是python3make check是跑不通的,需要找到Makefile的第25行, 修改为PYTHON = python

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ java capers.Main story "Once upon a time, there was a beautiful dog."
Once upon a time, there was a beautiful dog.

$ java capers.Main story "That dog was named Fjerf."
Once upon a time, there was a beautiful dog.
That dog was named Fjerf.

$ java capers.Main story "Fjerf loved to run and jump."
Once upon a time, there was a beautiful dog.
That dog was named Fjerf.
Fjerf loved to run and jump.

$ java capers.Main dog Mammoth "German Spitz" 10
Woof! My name is Mammoth and I am a German Spitz! I am 10 years old! Woof!
$ java capers.Main dog Qitmir Saluki 3
Woof! My name is Qitmir and I am a Saluki! I am 3 years old! Woof!
$ java capers.Main birthday Qitmir
Woof! My name is Qitmir and I am a Saluki! I am 4 years old! Woof!
Happy birthday! Woof! Woof!
$ java capers.Main birthday Qitmir
Woof! My name is Qitmir and I am a Saluki! I am 5 years old! Woof!
Happy birthday! Woof! Woof!

准备开始Gitlet:

Gitlet

参考资料

  • Git pro book

Note

  • 往年课程的Slide很好地以图片的形式介绍了Gitlet中的各个命令的实现。
  • Gitlet Persistence
  • 这里有Git比较详细的图解。
  • 仔细阅读gitlet.Utils中封装好的方法。
  • 编写设计文档,规格示例
  • Git的内部结构
    • blob(Binary Large Object): 文件的保存内容。一个文件可能对应多个blob:每个blob在不同的Commit中被跟踪。
    • Tree: 映射文件名称(name)到blob的reference,或者是映射文件名称到其他tree(子目录)的引用。
    • commits: 日志消息,其他元数据(提交日期、作者等), 对树的引用和对Parent Commit的引用。repository还维护了分支头到提交引用的映射(以便某些重要的提交具有符号名称)
  • Gitlet简化的部分
    • 将树合并到Commit中而不处理子目录
    • 只能两个父级合并
    • 元数据仅包含时间戳和日志消息,因此Commit将由日志消息, 时间戳, 文件名Blob引用的映射,父引用, (用于Merge的)第二个父引用来组成。
  • 每个blob和每个commit都有一个唯一的整数id,用作该对象的引用
  • 当使用SHA-1哈希一个Commit时,会包括所有的数据和引用。
  • 区分CommitBlob的哈希值,一种方法是可以在两个类中各自定义一个属性字段来实现。
  • 不应该在Main中做完所有事情,而是将需要实现的内容封装成一个函数到Repository类中去。
  • 序列化最好使用TreeMap而不是HashMap
  • Gitlet不会出现deteched head(分离头部)的状态。指的是当前HEAD指向的Commit不表示任何一个分支。

注意事项

  • terminal下出现了Esc无法退出insert mode而且还进入了Itellij的编辑界面。解决方案看此处
  • 暂存区包含两个区域, stage for additionstage for removal
  • gitlet中一条命令对应运行一次程序,因此需要将用到的数据结构序列化为字节流后保存到文件中。
  • 在实验一开始给的Utils类中, 不需要使用createNewFile()来创建文件, 因为writeObject()中会调用WriteContent(),若文件不存在则会自动创建,或者文件存在则覆盖掉它。
  • 注意要在当前目录下执行make check,测试文件可能跑通(可能需要操作src/目录中的文件)。
  • 注意每次更新数据时都需要序列化对象和反序列化文件, 保证更新的数据能够及时存储到文件中。
  • 也需要将blobs的映射集合序列化存储起来(同样用TreeMap),checkout命令要使用(用SHA-ID(文件名+文件内容)来作为文件名)。在add命令将blob和文件名映射放入stage for addition的同时,将blob和文件内容的映射放入blobs集合。
  • blob生成blobId(sha-1 id)的时候,需要通过文件名+文件内容(字节流)生成。若只用文件内容生成blobId,不同名的两个文件内容都为空时,生成的blobId是一致的, 使用文件名+文件对象生成的sha-1哈希也是同样的结果。
  • mkdirs()方法可以一同创建之前未存在的父目录。
  • 注意写文件内容时用writeContent()而不是writeObject()。注意到writeObject会在调用writeContent前将第二个参数先序列化, 若将字节数组再序列化,可能会在内容中写入额外的信息。
  • Untracked file,既没有被当前commit跟踪,也没有被放到stage area里。或者是已经被stage for removal但是又重新创建了。(考虑到我这里rm情况下没有将其从暂存区删除,仅仅是将它从stage for removal删除了而已)。
  • 保证分支头只有一个, get(0)可以直接取(这里我将分支做所处的位置做成了一个目录,目录中是用分支名来命名的文件, 方便直接取头Commit)。
  • 暂存区使用blobId映射文件对象不可取(序列化后提取的文件内容还是的却决于当前工作目录下的内容,不符合预期),试试映射文件内容(字符串readContentsAsString)。经过调试发现必须得映射字节流,因为前面生成blobId中需要文件名+文件内容(字节流)
  • 注意Utils中的restrictedDelete()方法,是如果文件存在则删除, 如果文件对象不存在则删除未成功返回false, 给我们实现rm指令来删除当前工作目录中的文件提供了一个很好的帮助。
  • 需要在每次commit命令执行时都更新分支头目录中的数据以及其对应branches目录中的数据。
  • 除了init之外剩下的命令在处理之前都需要检测当前.gitlet目录是否存在, 若不存在则输出报错信息。
  • 在开始项目任何一个功能前一定要有一个Big picture和整体思路,确保在实现过程中能够顺利进行。

测试

  • 查看tester.py文档中一些相应的操作来对应测试文件。测试文件可以从AG中获取。

init

  • 创建.gitlet目录,并在其中新建一些文件来存储序列化信息。
  • init会自动创建一个包含initial commit信息的Commit开始

add

  • 暂存已经暂存过的文件,会用新内容来覆盖先前的内容。
  • 若当前需要暂存的文件与Commit跟踪的文件版本一致(包括其修改之后又还原内容的文件,blobId都是一致的),则不对它进行处理。
  • 若当前添加文件在Stage for removal中,则将它从里面删除。
  • gitlet一次只能添加一个文件。
  • 维护一个TreeMap来存放stage for addition文件。

commit

  • 需要考虑在当前跟踪的文件可能在新的Commit中未被跟踪,与rm指令将文件Stage for removal相关。
  • 当前Commit跟踪的blob需要加上其父Commit跟踪的blob(前提是父Commit中跟踪的blob未被当前Commit跟踪的文件覆盖过)。
  • commmit后将stage area清空,也就是stage for addstage for removal
  • 若没有文件被暂存,或没有提交信息则中止。

rm

  • 若当前文件正在stage for addition,则将它从里面移除。
  • 若文件在当前Commit中被跟踪,则将其放入stage for removal暂存区中, 同时将本地目录文件删除。
  • 只考虑被当前Commit跟踪的情况下
  • 维护一个TreeMap来存放stage for removal文件。

log

  • 注意显示的是太平洋标准时间而不是UTC。
  • 当前操作系统显示时间得调成英文显示,否则打印log会出现中文字样

find

  • 通过Utils类中给的方法plainFilenamesIn, 直接遍历commit Id所处的目录,若出现commit对象不同但提交信息一致的打印情况,分行打印各自的commit id

status

  • TreeMap数据结构会按照字典序来排序。
  • Untrack file,既未跟踪也未暂存。未跟踪将当前目录下的文件名和当前最新Commit中track的文件名做对比。若未包含则说明未跟踪。同样包括已经准备删除但又重新创建的文件(说明其还在stage for removal里,因为stage area只在commit时删除)。思考该该怎么标记? rm之后add的文件?
  • modified, 同样与Commit中track对比,若文件名相同,但BlobId不同则说明被修改过。
  • 遍历当前跟踪的文件,使用exist判断文件对象是否存在以及是否存在于stage for removal,就能判断文件是否被delete

checkout

  • 有三类checkuout:
    • java gitlet.Main checkout – [file name]
    • java gitlet.Main checkout [commit id] – [file name]
    • java gitlet.Main checkout [branch name]
  • checkout切换分支,且当前目录下文件有文件未被跟踪,则打印提示信息并退出。
  • 注意切换完成,将新分支放入heads目录的同时,从heads目录中删除先前的分支。
  • checkout filename将当前文件从head commit中拿到当前工作目录。
  • 切换到另一个分支要将暂存区清空。要将当前commit中跟踪了但切换到的分支commit中没有跟踪的文件删除(如果有则覆盖)。
  • 考虑两种情况:
    • checkout的目标分支跟踪的文件比当前目录的文件多,直接写就行了
    • checkout的目标分支跟踪的文件比当前目录的文件少,需要删除本地目录中多余的文件
    • 这两中情况都需要遍历当前目录中的所有文件名来确定。
  • 一个错误的策略是切换之前先将当前当前文件下所有的都删除,然后再将切换到的分支commit跟踪的文件写进CWD当前工作目录, 在后续将跟踪文件写入当前工作目录时,取不到文件对象了。
  • checkout branchName时需要将暂存区清空。
  • 后面还有一个checkout shortid的测试,需要包括通过commitid的前8就能切换到不同的分支。和commitId一样序列化到磁盘上的文件就行。
  • gitlet不允许删除提交。

reset

  • 神似checkout branchName, 只是最后是将当前branch回退而不是切换分支。

branch

  • 只新建一个分支(即在branches目录中新建一个序列化的分支Commit文件), 但是并未切换到新建的分支。

merge

  • 将给定分支名为branchName的文件合并到当前分支中。
  • 如何找到split point? 考虑到gitlet只支持两个分支合并,因此可以把新建分支的commit作为split point?! hhh笑死ucb学生视频里第一个提问和我想得一模一样。我感觉这种方法在gitlet中可行,但在多分支合并时就不行了。还是得去过一遍图的遍历这一节的slide。
  • Latest common ancestor其实指的也就是split point
  • 如果给定分支是split point则不做任何处理。
  • 无论是否发生冲突,merge结束后新建一个Commit
  • 如果当前分支是split point, 则切换到给定分支。
  • 发生冲突且修改的内容不同只写当前分支的冲突文件。
  • 需要复用addrm以及commit命令。
  • git提交后的分布可以看作是个direct graph
  • 可以通过将从init commit到当前分支经过的commit放到一个list中,同时将要给定分支的路径也放到一个set中,再通过按指定顺序遍历(由当前分支开始)当前分支的list,使用contains方法来确定遍历经过的commit是否在分支路径的set中。需要使用BFS来遍历。
  • 之前的策略是在切换分支(checkout, reset)时才将branches目录中的当前分支信息更新,这在这一步中获取当前分支对象是危险的,因此需要调整策略,在每一次commit更新HEAD目录时和reset时,都更新Branches中当前分支信息。这样就能保证当前分支在Head目录和Branches目录中的信息是同步一致的了。
  • 注意Java中null在字符串比较时不能用equals()来比较。
  • 别忘了改写冲突文件时需要生成新的blobId放入暂存区中在新的合并Commit中加入当前目录。

处理case

  • Image
  • 想到了一个比较情况的好方法,将当前分支,给定分支以及split point三个集合中含有的文件名的并集放到集合里。比较时需要各个集合的BlobId和文件名的映射,以及BlobId和content的映射。
  • 由两个值来区分所有情况分别是isPresentIsModified
  • Modifed只需要通过当前分支分支或给定分支, 与split point blobId比较来区分就行了, 程序里我将不存在的文件的blobId赋值为空串。
  • 当前分支和给定分支都modifed也就是case3还需要细分发生冲突,还是不发生冲突。直接通过比较当前分支和给定分支commit Id即可。
  • git pro中的原话, 此时 Git 做了合并,但是没有自动地创建一个新的合并提交。 Git 会暂停下来,等待你去解决合并产生的冲突。 你可以在合并冲突后的任意时刻使用 git status 命令来查看那些因包含合并冲突而处于未合并(unmerged)状态的文件。这点与Gitlet还是不同的。
  • 发生冲突的内容差异会自动写入冲突文件(按合理性来说只需要写入当前分支头即可)如, 需要解决冲突后,需要打印提示信息,合并的提交跟踪冲突文件:
    1
    2
    3
    4
    5
    <<<<<<< HEAD
    contents of file in current branch
    =======
    contents of file in given branch
    >>>>>>>
  • 处理好每个情况后将每个文件对应的result添加到暂存区等待新的Commit。result的内容为当前分支的内容也需要stage for addition, 但不需要放入blobs集合中。
  • getBytes方法可以将字符串转化为字节数组(byte[])。

Image