CS61B project2 学习笔记

包括project2的思路与实现细节

项目介绍

概述

Gitlet 希望实现 Git 当中一部分的内容。其中主要的功能有:

  • commit
  • checkout
  • log
  • branches
  • merge

理论上每一次提交都可以更改多个文件。不过,为了简洁性,下面许多的例子每次都只修改一个文件。

每一个 Commit 都会有父节点,指向上一个提交。因此,可以通过链表的方式来可视化。HEAD 指针代表现在工作工作区的状态。通常,HEAD 指针会在链表的尾部,代表最新的 commit。不过,如果返回到之前某个节点,那么 HEAD 就会指向对应 commit。这时候状态为 detached head state

注意:Gitlet 中没有 checkout 命令,所以不会出现 detached head state 情况。Gitlet 中的 reset 命令会同时移动 branch 的指针。

当有多个版本的时候,Commit 组成的数据结构就不再是链表,而是树。每个分支的末尾都会有对应分支的指针。在某个时刻,HEAD 只会指向其中一个分支。

需要注意,commit tree 是不能摧毁的。只能将新的节点加入,但不能删除旧节点。

在使用不同对象的时候,不能简单地使用指针,否则如果读入某一个 commit,则所有之前的 commit 也都会被激活。这会导致读入消耗更多的时间。因此,不应该储存指针,而应该储存对应物品的名字。

内部结构

有一些重要的对象:

  • blobs:储存文件的内容。一个文件可能对应多个 blobs。
  • trees:目录的数据结构,将相应的名字对应到 blobs 和其他树。
  • commits:信息,其他 metadata,以及父节点指针。同时储存从 branch heads 到 commit 的 hash 值的指针。

Gitlet 相比于 Git,会进一步简化:

  • 不处理子文件夹
  • 限制 merge 到两个 parents
  • commit 的 metadata 只有 timestamp 和 message。

最好区分 commits 和 blobs 的 hashing。可以放在不同的文件夹之中。如果两个文件的 hashing 一样,就认为它们的内容一样。

Spec

总体预期

不应该在 Main 类当中做所有事。而应该使用 Repository 类,在 Main 当中做好调用的工作。

  • 所有的 repo 文件储存在 .gitlet 中,并且 gitlet 只有在存在 .gitlet 的时候,才能正常工作(除了 init 命令)。
  • 需要遵循各个命令的运行时间或内存占用要求。
  • 有些命令有明确要求的错误信息,并且都以分号结束
  • 有一些错误情况:
    • 如果没有提供命令行参数,打印 Please enter a command. 并退出
    • 如果输入的命令不存在,打印 No command with that name exists. 并退出
    • 如果输入命令的格式、个数有问题,打印 Incorrect operands. 并退出
    • 如果输入的命令需要在初始化后的工作区才能使用,则打印 Not in an initialized Gitlet directory.
  • 只打印 spec 中所说的内容
  • 使用 System.exit(0) 退出,并且就使用 0
  • Dangerous 表示该命令会修改当前工作区中的文件

开始

万事开头难。这个项目一开始只提供了 Main、Commit 和 Repository 类,并且很简略,剩下的类都需要自己根据需要构造。

Main

在 Main 函数中,应该简短,引用其他函数的内容。因此,首先判断参数的个数。根据第一个参数的内容进行讨论。

init

使用 Repository 类的初始化器来实现新建仓库。在 Main 函数中,也就是一个 new Repository();

在 Repository 类中,首先创建许多不同的目录。我的目录结构如下:

  • .gitlet/
    • blobs/
    • commits/
    • branches/
    • HEAD

为了实现初始化,需要完成以下任务:

  1. 如果已经存在 .gitlet,则视作错误,退出程序
  2. 创建各个文件夹
  3. 创建初始 Commit
  4. 创建初始 Branch —— master
  5. 创建头 HEAD
  6. 储存上述新建的对象

Commit

在这一步,我仅仅实现了 message、timeStamp 和 parent 作为实例变量,还没有加入文件指针。

对于构造器,输入为信息和父节点。为了存储时间,我使用了 java.sql.Timestamp 类,因为输入看起来比较直接、美观。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
/** Construct a new commit with message and parent. */  
public Commit(String msg, Commit parent) {  
    this.message = msg;  
    this.parent = parent;  
    if (parent == null) {  
        this.timeStamp = EPOCH_TIME.toString();  
    } else {  
        Timestamp timeNow = new Timestamp(System.currentTimeMillis());  
        this.timeStamp = timeNow.toString();  
    }  
}

Branch 和 HEAD

为了实现指针的功能,我创建了 Pointer 类,相当简单。

1
2
3
4
5
6
7
public class Pointer implements Serializable {  
    public String next;  
  
    public Pointer(String sha1) {  
        next = sha1;  
    }  
}

Branch 类代表分支指针。显然,分支指针可能有多个名字,因此增加相应实例变量 name。

1
2
3
4
5
6
7
public class Branch extends Pointer {  
    private String name;  
    public Branch(String sha1, String name) {  
        super(sha1);  
        this.name = name;  
    }
}

Head 类代表头指针。只用继承 Pointer 类的构造器就行。

Hash 与储存

Commit 需要使用 sha 1 算法。在提供的 Utils.sha1(Object... vals) 中,支持字节数组或者字符串的混合输入。调用该方法,得到 Commit 的 toSha1() 方法:

1
2
3
4
/** Convert message and timeStamp to sha1. */  
public String toSha1() {  
    return Utils.sha1(message, timeStamp);  
}

为了储存 hash,在 Repository 中创建 storeInHashTable(File path, Serializable o , String sha1) 方法。可以利用 hash 值得前两个字符创建文件夹,这样就起到了哈希表的作用,方便查找。

1
2
3
4
5
6
7
8
9
/** Using object and its sha1 to store in path. */ 
public static void storeInHashTable(File path, Serializable o , String sha1) {  
    File folder = join(path, sha1.substring(0, 2));  
    File file = join(folder, sha1);  
    if (!folder.exists()) {  
         folder.mkdir();  
    }  
    writeObject(file, o);  
}

因此,可以实现 Commit 的 store() 方法。

1
2
3
public void store() {  
    Repository.storeInHashTable(Repository.COMMITS_DIR, this, toSha1());  
}

对于 master 和 HEAD 来说,直接利用名字来储存。在 Repository 中创建方法:

1
2
3
4
5
/** Store current object by name. */  
public static void storeInName(File path, Serializable o, String name) {  
    File file = join(path, name);  
    writeObject(file, o);  
}

实现

最后,就完成了 init 命令的初始化,对应代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public Repository() {  
    if (GITLET_DIR.exists()) {  
        Main.exitWithMessage("A Gitlet version-control system "  +  
                "already exists in the current directory.");  
    }  
    GITLET_DIR.mkdir();  
    BLOBS_DIR.mkdir();  
    COMMITS_DIR.mkdir();  
    BRANCHES_DIR.mkdir();  
    Commit initCommit = new Commit("initial commit", null);  
    Branch master = new Branch(initCommit.toSha1(), "master");  
    Head head = new Head(initCommit.toSha1());  
    initCommit.store();  
    master.store();  
    head.store();  
}

add

Blob 与 Commit

这个类是用来存储文件的,只有一个实例变量 text。方法包括 toSha1()store()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
public class Blob {  
    public String text;  
    public Blob(String text) {  
        this.text = text;  
    }  
  
    public String toSha1() {  
        return Utils.sha1(text);  
    }  
  
    /** Store text in hash name. */  
    public void store() {  
        String sha1 = sha1(text);  
        File folder = join(Repository.BLOBS_DIR, sha1.substring(0, 2));  
        File file = join(folder, sha1);  
        if (!folder.exists()) {  
            folder.mkdir();  
        }  
        writeContents(file, text);  
    }
}

在 Commit 类中,构造 read() 方法,从而根据 HEAD 指针来获取当前的提交节点。

1
2
3
4
public static Commit read(String sha1) {  
    File file = Repository.fileFromHashTable(Repository.COMMITS_DIR, sha1);  
    return readObject(file, Commit.class);  
}

Main 与 Repository

在 Main 函数中确认参数个数,并且使用 Repository 的 load () 方法来加载得到新的 Repo,然后使用 add(String name) 方法。

1
2
3
4
5
case "add":  
    validateArgsNum(args, 2);  
    Repository currRepo = Repository.load();  
    currRepo.add(args[1]);  
    break;

在 Repository 类中,如果发现文件不存在,就报错。否则,阅读文件的内容,存储到 Blob 类中。然后,读取当前 Commit。检查当前 Commit 的文件列表中是否包含暂存区中对应文件。如果包含且 sha 1 值相等,就代表这个文件应该从暂存区中移除,同时结束程序。然后,储存 blob,将文件加入暂存区,并且储存暂存区。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
public void add(String fileName) {  
    File file = join(CWD, fileName);  
    if (!file.exists()) {  
        Main.exitWithMessage("File does not exist");  
    }  
    String fileContents = Utils.readContentsAsString(file);  
    Blob blob = new Blob(fileContents);  
    String sha1 = blob.toSha1();  
    Commit currCommit = Commit.read(head.next);  
    if (currCommit.getFiles().get(fileName) != null &&  
            currCommit.getFiles().get(fileName).equals(sha1)) {  
        area.removeFileAddition(fileName, sha1);  
        System.exit(0);  
    }  
    blob.store();  
    area.addFileAddition(fileName, sha1);  
    area.store();  
}

Commit

Main 和 Repo

如果只有一个参数,需要输出 Please enter a commit message.。接着,使用 Repository 的 commit(String msg) 方法。

为了提交,首先需要检查暂存区中是否有更改。如果没有就输出 No changes added to the commit. 并退出。否则,读取当前 Commit。然后,根据当前 Commit 创建新的 Commit,并且更新其中文件。接着,清理暂存区,更新 HEAD,储存新的 Commit,储存暂存区和 HEAD。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public void commit(String msg) {  
    if (area.additionArea.isEmpty() && area.removalArea.isEmpty()) {  
        Main.exitWithMessage("No changes added to the commit.");  
    }  
    Commit currCommit = Commit.read(head.next);  
    Commit newCommit = currCommit.newCommit(msg);  
    newCommit.updateFiles(area);  
    area.clear();  
    head.next = newCommit.toSha1();  
    newCommit.store();  
    area.store();  
    head.store();  
}

Commit

首先,需要修改原先的初始化器。并且加入了 files 变量来储存文件和指针的对应关系。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public Commit(String msg, String parent) {  
    this.message = msg;  
    this.parent = parent;  
    if (parent.isEmpty()) {  
        this.timeStamp = EPOCH_TIME.toString();  
        this.files = new HashMap<>();  
    } else {  
        Timestamp timeNow = new Timestamp(System.currentTimeMillis());  
        this.timeStamp = timeNow.toString();  
		this.files = read(parent).files;
    }  
}

要从当前节点创建新的节点,显然要输入当前节点的 sha 1 值。

1
2
3
public Commit newCommit(String msg) {  
    return new Commit(msg, this.toSha1()); 
}

因为 Utils.sha1() 只接受多个字符串或者字节矩阵作为输入,因此要输入文件列表中的 hash 值,就需要遍历 files。

1
2
3
4
5
6
7
public String toSha1() {  
    StringBuilder tmp = new StringBuilder();  
    for (String name : files.keySet()) {  
        tmp.append(files.get(name));  
    }  
    return Utils.sha1(message, timeStamp, parent, tmp.toString());  
}

rm

Repository

如果要删除的文件名在暂存区的增加列表当中,就去除。如果在当前提交中也有要删除的文件名,就需要将该文件加入暂存区的删除区域,并且手动删除这个文件。如果上面两个都没有发生,就退出并打印 No reason to remove the file.。最后储存暂存区。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public void rm(String fileName) {  
    boolean flag = false;  
    String result1 = area.getAdditionArea().remove(fileName);  
    if (result1 != null) {  
        flag = true;  
    }  
    Commit currCommit = Commit.read(head.next());  
    if (currCommit.files().containsKey(fileName)) {  
        String sha1 = currCommit.files().get(fileName);  
        area.getRemovalArea().put(fileName, sha1);  
        File rmFile = join(CWD, fileName);  
        Utils.restrictedDelete(rmFile);  
        flag = true;  
    }  
    if (!flag) {  
        Main.exitWithMessage("No reason to remove the file.");  
    }  
    area.store();  
}

log

为了显示记录,需要首先打印当前提交的信息。然后让指针指向上一个节点,继续打印相应信息,重复直到到达最初的提交。

Commit

首先,让 Commit 类继承 Dumpable 接口。然后,按照格式打印。这里的时间用到了 SimpleDateFormat。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@Override  
public void dump() {  
    System.out.println("===");  
    System.out.println("commit " + toSha1());  
    SimpleDateFormat dateFormat = new SimpleDateFormat("EEE MMM d HH:mm:ss yyyy Z", Locale.ENGLISH);  
    dateFormat.setTimeZone(TimeZone.getTimeZone("GMT+8:00"));  
    String formattedDate = dateFormat.format(timeStamp);  
    System.out.println("Date: " + formattedDate);  
    System.out.println(message);  
    System.out.println();  
}

Repository

只需要使用一个指针遍历即可。

1
2
3
4
5
6
7
8
public void log() {  
    Head p = head;  
    while (!p.next().isEmpty()) {  
        Commit currCommit = Commit.read(p.next());  
        currCommit.dump();  
        p.updateNext(currCommit.getParent());  
    }  
}

中间部分

Checkout 1

Checkout 的第一个命令可以让文件恢复到当前 commit 的状态。该命令会覆盖当前工作区中的文件。

在 Main 中,调用 checkoutFile(String fileName)。其代码如下:

1
2
3
4
case "checkout":  
    if (args.length == 3) {  
        currRepo.checkoutFile(args[2]);  
    }

在 Repo 中,实现该方法:

1
2
3
4
5
6
7
8
public void checkoutFile(String fileName) {  
    String sha1 = currCommit.files().get(fileName);  
    if (sha1 == null) {  
        Main.exitWithMessage("File does not exist in that commit.");  
    }  
    Blob blob = Blob.read(sha1);  
    blob.writeToCWD(fileName);  
}

Blob 的 writeToCWD 方法如下:

1
2
3
4
public void writeToCWD(String fileName) {  
    File file = join(Repository.CWD, fileName);  
    Utils.writeContents(file, text);  
}

Checkout 2

这个 checkout 命令可以回退到特定 commit 的特定文件,然后覆盖当前工作区。在 Main 中:

1
2
3
else if (args.length == 4) {  
    currRepo.checkoutCommitFile(args[1], args[3]);  
}

在 Repo 中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public void checkoutCommitFile(String pSha1, String fileName) {  
    File commitFile = fileFromHTPrefix(COMMITS_DIR, pSha1);  
    if (commitFile == null) {  
        Main.exitWithMessage("No commit with that id exists.");  
        return;  
    }  
    Commit commit = Commit.read(commitFile.getName());  
    String blobSha1 = commit.files().get(fileName);  
    if (blobSha1 == null) {  
        Main.exitWithMessage("File does not exist in that commit.");  
    }  
    Blob blob = Blob.read(blobSha1);  
    blob.writeToCWD(fileName);  
}

其中,fileFromHTPrefix 可以实现根据 sha 1 值的前面一部分,找到对应的文件并返回。然后,根据文件读入 commit,并找到相应 blob 的 sha 1。最后,将 blob 写入当前工作区。

要想从 hashTable 中根据 sha 1 的前缀读取文件,需要如下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public static File fileFromHTPrefix(File path, String pSha1) {  
    File folder = join(path, pSha1.substring(0, HASH_PREFIX_LENGTH));  
    List<String> fileSha1s = plainFilenamesIn(folder);  
    if (fileSha1s != null) {  
        for (String sha1 : fileSha1s) {  
            if (sha1.startsWith(pSha1)) {  
                return join(folder, sha1);  
            }  
        }  
    }  
    return null;  
}

这里用到了 String 的 startsWith 方法。

Status

因为后面涉及到分支,之前我设计的 branch 实际上不方便读取、存储。因此,改成 branches 类,直接存储所有分支,以及当前的分支。Branches 类拥有以下变量:

1
2
3
4
private Map<String, String> nameToSha1;  
private String activeBranchName;  
private String activeBranchSha1;  
private static final String name = "BRANCHES";

Status 可以呈现当前的状态。首先,实现 branches 和 area 的 dump 方法,也就是输出对应的内容。然后很容易实现。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
/** Print out current status. */  
public void status() {  
    System.out.println("=== Branches ===");  
    branches.dump();  
    System.out.println();  
    System.out.println("=== Staged Files ===");  
    area.dumpStagedFiles();  
    System.out.println();  
    System.out.println("=== Removed Files ===");  
    area.dumpRemovedFiles();  
    System.out.println();  
    System.out.println("=== Modifications Not Staged For Commit ===");  
    System.out.println();  
    System.out.println("=== Untracked Files ==="); 
    System.out.println();  
}

其中,branches 的 dump 方法要遍历 Map,按照字符串的顺序,然后在当前所在分支前加上星号。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@Override  
public void dump() {  
    Set<String> strs = nameToSha1.keySet();  
    String[] names = Repository.setToArray(strs);  
    Arrays.sort(names);  
    for (String name : names) {  
        if (name.equals(activeBranchName)) {  
            System.out.println("*" + name);  
        } else {  
            System.out.println(name);  
        }  
    }  
}

Global-log

全局 log 需要遍历所有的 commit,然后打印出来,不限次序。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public void globalLog() {  
    List<String> folderNames = plainFilenamesIn(COMMITS_DIR);  
    if (folderNames == null) {  
        System.out.println("COMMITS NOT EXIST!!!");  
        return;  
    }  
    for (String folderName : folderNames) {  
        File folder = join(COMMITS_DIR, folderName);  
        List<String> commitSha1s = plainFilenamesIn(folder);  
        for (String sha1 : commitSha1s) {  
            Commit commit = Commit.read(sha1);  
            commit.dump();  
        }  
    }  
}

Find

Find 需要遍历所有的 commits,然后找到具有相应 message 的 commit,打印出对应的 sha 1 值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public void find(String msg) {  
    String[] folderNames = COMMITS_DIR.list();  
    if (folderNames.length == 0) {  
        System.out.println("COMMITS NOT EXIST!!!");  
        return;  
    }  
    for (String folderName : folderNames) {  
        File folder = join(COMMITS_DIR, folderName);  
        List<String> commitSha1s = plainFilenamesIn(folder);  
        for (String sha1 : commitSha1s) {  
            Commit commit = Commit.read(sha1);  
            if (commit.getMessage().equals(msg)) {  
                System.out.println(commit.toSha1());  
            }  
        }  
    }  
}

后期部分

此时,需要进一步完成 branches 的功能,实现 checkout 3 功能,完成 branch、rm-branch 和 reset 命令。

Branches 功能

为了实现 branches 的更新当前分支功能,可以创建 setActiveBranch 方法,根据 branch 名称来切换到相应的 branch。

1
2
3
4
5
6
7
8
9
/** Set active branch. */  
public void setActiveBranch(String name) {  
    String sha1 = nameToSha1.get(name);  
    if (sha1 == null) {  
        Main.exitWithMessage("Branch name not found!!!");  
    }  
    activeBranchName = name;  
    activeBranchSha1 = sha1;  
}

为了创建新的 branch,创建 putBranch 方法。

1
2
3
4
/** Put branch into branches. */  
public void putBranch(String name, String sha1) {  
    nameToSha1.put(name, sha1);  
}

Branch

在 Repository 中创建 branch 方法。如果当前的名字已经存在,就返回报错。否则,就创建新的 branch 指向当前节点。

1
2
3
4
5
6
7
8
/** Create new branch. */  
public void branch(String branchName) {  
    if (branches.getMap().containsKey(branchName)) {  
        Main.exitWithMessage("A branch with that name already exists.");  
    }  
    branches.putBranch(branchName, head.next());  
    saveState();  
}

Rm-branch

Rm-branch 可以移除一个不是当前 branch 的 branch。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
/** Remove branch with that name. */  
public void rmBranch(String branchName) {  
    if (!branches.getMap().containsKey(branchName)) {  
        Main.exitWithMessage("A branch with that name does not exists.");  
    }  
    if (branches.getActiveBranchName().equals(branchName)) {  
        Main.exitWithMessage("Cannot remove the current branch.");  
    }  
    branches.removeBranch(branchName);  
    saveState();  
}

其中,Branches 类的 removeBranch 方法如下:

1
2
3
4
/** Remove branch. */  
public void removeBranch(String name) {  
    nameToSha1.remove(name);  
}

Checkout 3

这个命令输入相应的 branch 名称,然后 checkout 到相应的 commit 上。如果不存在相应 branch 名称,或者就是当前的 branch,那么就报错退出。否则,获取相应 branch 的 sha 1,再来到相应的 commit。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
/** Checkout branch. */  
public void checkoutBranch(String branchName) {  
    if (!branches.getMap().containsKey(branchName)) {  
        Main.exitWithMessage("No such branch exists.");  
    }  
    if (branches.getActiveBranchName().equals(branchName)) {  
        Main.exitWithMessage("No need to checkout the current branch.");  
    }  
    String checkoutCommitSha1 = branches.getMap().get(branchName);  
    checkoutCommit(checkoutCommitSha1);  
    branches.setActiveBranch(branchName);  
    saveState();  
}

其中,checkoutCommit 方法有些复杂。首先,获取当前工作区文件名列表。如果其中有没有标记的文件,就报错退出。否则,读取需要 checkout 的 commit,然后得到文件列表,写入当前工作区。接着,再次遍历原先文件列表,如果原先文件名不在现在的 commit 中,那么就删除相应的文件。最后,将 head 更新为这个 commit,并且清空缓存区。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/** Checkout to commit. */  
private void checkoutCommit(String commitSha1) {  
    List<String> fileNames = plainFilenamesIn(CWD);  
    if (fileNames != null) {  
        for (String fileName : fileNames) {  
            if (!isTrackedFile(fileName)) {  
                Main.exitWithMessage("There is an untracked file in the way;" +  
                        " delete it, or add and commit it first.");  
            }  
        }  
    }  
    Commit checkoutCommit = Commit.read(commitSha1);  
    Map<String, String> newFiles = checkoutCommit.getFiles();  
    for (String fileName : newFiles.keySet()) {  
        Blob blob = Blob.read(newFiles.get(fileName));  
        blob.writeToCWD(fileName);  
    }  
    if (fileNames != null) {  
        for (String fileName : fileNames) {  
            if (!newFiles.containsKey(fileName)) {  
                File delFile = join(CWD, fileName);  
                restrictedDelete(delFile);  
            }  
        }  
    }  
    head.updateNext(commitSha1);  
    area.clear();  
}

其中,isTrackedFile 检查文件是否被标记。

1
2
3
4
5
6
7
8
/** Check whether tracked a file with that name. */  
public boolean isTrackedFile(String fileName) {  
    if (area.getRemovalArea().containsKey(fileName)) {  
        return false;  
    }  
    return currCommit.getFiles().containsKey(fileName)  
            || area.getAdditionArea().containsKey(fileName);  
}

Reset

Reset 接受一个参数,就是相应 commit 的 id,然后跳转到相应的 commit。

如果不能获取相应 commit,就报错退出。否则,checkout 相应 commit,然后将当前的 branch 指向对应 commit。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
/** Reset to commit id. */  
public void reset(String commitId) {  
    File commitFile = fileFromHTPrefix(COMMITS_DIR, commitId);  
    if (commitFile == null) {  
        Main.exitWithMessage("No commit with that id exists.");  
    }  
    Commit commit = Commit.read(commitFile);  
    checkoutCommit(commit.toSha1());  
    String branchName = branches.getActiveBranchName();  
    branches.getMap().put(branchName, commit.toSha1());  
    saveState();  
}

最后的 Merge

这部分我拖到了 10 月 4 号才写,放了一个多月,可能写得不好,见谅。

该函数接受一个 branch 作为输入,然后看当前头结点仍否和指定的 branch 进行合并。

首先,需要做一些基础的判断——当前工作区是否为空,输入的 branch 名字是否存在,以及是否当前已经处在了 branch 中。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public void merge(String branchName) {  
    if (!area.isClean()) {  
        Main.exitWithMessage("You have uncommitted changes.");  
    }  
    if (branches.getMap().get(branchName) == null) {  
        Main.exitWithMessage("A branch with that name does not exist.");  
    }  
    if (branches.getActiveBranchName().equals(branchName)) {  
        Main.exitWithMessage("Cannot merge a branch with itself.");  
    }
}

接着,代码需要根据相应的分支名字,找到分支对应的节点。然后,根据当前节点和分支节点,找到两者共同的第一个节点。这个其实是一个常见的链表题目,leetcode 上面也有。只需要两个节点都遍历完整个链表,分别拥有两个长度。然后,对齐两个长度,让两个指针每次都往前走一步,这样就可以找到公共节点。在这里,我使用 findSplitPoint 函数来实现这个功能。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private String findSplitPoint(String branchName) {
	// my thought comes from https://programmercarl.com/%E9%9D%A2%E8%AF%95%E9%A2%9802.
	// 07.%E9%93%BE%E8%A1%A8%E7%9B%B8%E4%BA%A4.html#%E6%80%9D%E8%B7%AF
	int lenA = ancestorLength(branches.getActiveBranchSha1());
	int lenB = ancestorLength(branches.getMap().get(branchName));
	String curA = head.next();
	String curB = branches.getMap().get(branchName);
	if (lenB > lenA) {
		int tmpNum = lenA;
		lenA = lenB;
		lenB = tmpNum;
		String tmpStr = curA;
		curA = curB;
		curB = tmpStr;
	}
	int gap = lenA - lenB;
	while (gap > 0) {
		curA = ancestor(curA);
		gap -= 1;
	}
	while (!curA.isEmpty()) {
		if (curA.equals(curB)) {
			return curA;
		}
		curA = ancestor(curA);
		curB = ancestor(curB);
	}
	return null;
}

接下来,还有一些特殊情况。

  • 如果公共节点就是要切换的节点,那么说明要切换的节点就是当前节点的父节点,不用进行操作。
  • 如果公共节点就是当前节点,那就直接 checkout 到那个 branch 就可以(fast-forward)。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
String splitPointSha1 = findSplitPoint(branchName);  
String mergeCommitSha1 = branches.getMap().get(branchName);  
String currentCommitSha1 = head.next();  
if (splitPointSha1.equals(mergeCommitSha1)) {  
    Main.exitWithMessage("Given branch is an ancestor of the current branch.");  
}  
if (splitPointSha1.equals(currentCommitSha1)) {  
    checkoutBranch(branchName);  
    Main.exitWithMessage("Current branch fast-forwarded.");  
}

接下来,创建一个集合,其中储存着三个节点当中所有出现过的文件。

1
2
3
4
5
Commit splitCommit = Commit.read(splitPointSha1);  
Commit mergeCommit = Commit.read(mergeCommitSha1);  
Set<String> fileNames = new HashSet<>(splitCommit.files().keySet());  
fileNames.addAll(mergeCommit.files().keySet());  
fileNames.addAll(currCommit.files().keySet());

然后,遍历这些文件,对于每一个文件分别判断。这里的情况超级多,我现在都有些忘了,只能把这个辅助函数放在这里。这个函数接受三个节点中文件的 sha 1作为输入,然后返回合并后应该存在的文件。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/** Return the result blob SHA1 of merge. */
private String mergeFile(String splitSha1, String headSha1, String otherSha1) {
	if (headSha1 == null && otherSha1 == null) {
		// case 3 special
		return "";
	}
	if (splitSha1 == null && headSha1 == null) {
		// case 5
		return otherSha1;
	}
	if (splitSha1 == null && otherSha1 == null) {
		// case 4
		return headSha1;
	}
	if (splitSha1 == null) {
		if (otherSha1.equals(headSha1)) {
			// case 3
			return otherSha1;
		} else {
			Blob newBlob = createMergeBlob(headSha1, otherSha1);
			newBlob.store();
			return newBlob.toSha1();
		}
	}
	if (splitSha1.equals(otherSha1) && headSha1 == null) {
		// case 7
		return "";
	} else if (splitSha1.equals(headSha1) && otherSha1 == null) {
		// case 6
		return "";
	} else if (splitSha1.equals(headSha1) && !splitSha1.equals(otherSha1)) {
		// case 1
		return otherSha1;
	} else if (!splitSha1.equals(headSha1) && splitSha1.equals(otherSha1)) {
		// case 2
		return headSha1;
	} else if (!splitSha1.equals(headSha1) && headSha1.equals(otherSha1)) {
		// case 3
		return headSha1;
	} else {
		Blob newBlob = createMergeBlob(headSha1, otherSha1);
		newBlob.store();
		return newBlob.toSha1();
	}
}

遍历之后,还需要经过一些判断,决定对当前工作区的操作。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
boolean hasMergeConflict = false;
	for (String fileName : fileNames) {
		String splitSha1 = splitCommit.files().get(fileName);
		String headSha1 = currCommit.files().get(fileName);
		String otherSha1 = mergeCommit.files().get(fileName);
		String newFileSha1 = mergeFile(splitSha1, headSha1, otherSha1);
		if (newFileSha1.isEmpty() && !(headSha1 == null)) {
			rm(fileName);
		} else if (!newFileSha1.equals(headSha1) && !newFileSha1.isEmpty()) {
			if (!newFileSha1.equals(otherSha1)) {
				hasMergeConflict = true;
			}
			Blob blob = Blob.read(newFileSha1);
			blob.writeToCWD(fileName);
			area.getAdditionArea().put(fileName, newFileSha1);
		}
	}

最后,打印相关的信息,提交新的 commit,并且保存当前状态。

1
2
3
4
5
6
7
8
String message = "Merged " + branchName + " into "
		+ branches.getActiveBranchName() + ".";
if (hasMergeConflict) {
	System.out.println("Encountered a merge conflict.");
}
commit(message, mergeCommitSha1);

saveState();

总结

不得不说,Gitlet 确实富有挑战性,同时相当有收获。

这个项目开始时,看上去相当复杂,有许多许多的部分需要考虑。“万事开头难”,这时候正是让人最迷茫的时候。在这个项目之前,接触到的许多编程作业,都会给你一些框架代码。区分起来大概如下:

  • 完全搭建好了整体的框架,包括具体的每一个函数,只是函数的内容空缺。给出详细的每个函数需要完成的功能,只需要按部就班完成函数就可以。
  • 部分搭建了整体的框架,拥有一些函数,但是有些函数实现起来较为复杂,需要自己分解任务。
  • 完全没有框架,只告诉你达成的效果怎么样,具体使用哪些类,都需要自己创造。

Gitlet 属于上述的第三种。好在,Gitlet 提供了许多视频资源,其中复现了 Gitlet 在背后应该做一些什么事。根据这些资源,你可以思考需要构建哪些类,然后,根据类去编写相应的方法,实现抽象。然后,自己根据类创建实例,达到相应的功能。

经过统计,这个项目单纯代码有 13 个类, 1000 多行,注释有 300 行,空白有 150 行。通过这样的工程,也确实锻炼了规范写代码的能力,以及 Debug 的能力。

最后,感慨一下 cs61b 的课程设计。在临近最后的课当中,教授反思了近年来这个课的 project。当年 gitlet 第一次提出的时候,没有详细的教学视频,也没有做好的集成测试,导致学生们经历了惨状。之后,随着资源的引入,这个项目的难度适当降低,不过学生们的反应各有千秋——有的觉得这个项目完全打击了他的自信心,让他之后不想投身编程;有的觉得这个项目虽然很麻烦,但是说活颇丰。

教授充分展示了学生们对课程的评价,每一年都根据反馈,不断优化课程,力求在难度、有趣程度和有用程度上达到平衡。我不紧反思起我国的计算机教育。身为某个 985 大学的学生,我所感受到的计算机课程,是十几年亘古不变的教材,是上世纪传承下来的 ppt,老师照着念一念,课程的作用要么十分书面,缺少实践,要么只给你一个大方向,没有详细的指导。

至于学生们对课程的反馈,那更是单向的,在不知名的教务平台上填写,没什么作用。以至于在官方平台之外,有其他的平台出现,起到了课程评价的效果。只有在其他平台,学生们才会说出自己对课程的真实想法,然而老师们是不关心这个的。

正如 csdiy 最后所说,希望随着今后的发展,该网站不再有用,因为学生们可以在课堂上接受最好的教育。我也希望之后某一天,不用再在其他平台对课程做出评价,而是可以通过官方的途径,直接向老师反馈,而老师也会给出回应。至于这究竟是不是我的异想天开,也只能之后再来看了。

使用 Hugo 构建
主题 StackJimmy 设计