Koalablog

[WIP]Gitlet 初步学习

表层

了解 Git 的基本使用之后,我们从表层一步步拆解他。Git 提供的接口是 CLI,那么就从这里开始。这里我的参考资料是 Gitlet,它的开篇 “Git in six hundred words” 是一些 Git 的简单使用说明,提到了以下这些常用命令,它们就是我们需要拆解的对象了。

  1. git init
  2. git add/rm <filename>
  3. git commit -m <commit_message>
  4. git clone <source_dir> <target_dir>
  5. git remote add <remote_name> <target_dir>
  6. git pull <remote name> <branch name>
  7. git fetch <remote name> <branch name>
  8. git merge <commit id>
  9. git branch <new branch name>
  10. git checkout <branch name>
  11. git push <remote name> <branch name>
  12. git diff [<ref1> <ref2>]

拆解(按 Gitlet 实现顺序)

1. git init

初始化 .git 文件夹( Gitlet 中是 .gitlet),文件夹中包含这些内容:

  • objects/:存储文件对象
  • refs/heads:存储引用标记
  • HEAD:当前 HEAD 所在位置,例如 ref: refs/heads/master\n
  • config:当前仓库的配置信息,Gitlet 只实现了 { bare: true },代表裸仓库。

<details> <summary>一次写入这么多文件比较麻烦,Gitlet 用了个取巧的办法是构建一个包含所有目录结构和文件内容的对象,然后递归遍历写入文件。</summary>

{
  // **writeFilesFromTree()** takes `tree` of files as a nested JS obj
  // and writes all those files to disk taking `prefix` as the root of
  // the tree.  `tree` format is: `{ a: { b: { c: "filecontent" }}}`
  writeFilesFromTree: function(tree, prefix) {
    Object.keys(tree).forEach(function(name) {
      var path = nodePath.join(prefix, name);
      if (util.isString(tree[name])) {
        fs.writeFileSync(path, tree[name]);
      } else {
        if (!fs.existsSync(path)) {
          fs.mkdirSync(path, "777");
        }

        files.writeFilesFromTree(tree[name], path);
      }
    });
  },
}

</details>

裸仓库(Bare Repo)是指不包含工作空间的仓库,也就是不包含真实的代码文件,仓库本身只有 .git 相关的内容。例如我们有一个仓库是 code,对应的裸仓库形态就是 code.git ,其内容和 code/.git/ 的内容是一样的。这种裸仓库一般作为远端中心仓库存在,任何一个 Git 仓库都可以作为 Source 被 Clone,但是只有裸仓库可以被作为 Remote 进行 Push,否则会被拦截。

2. git add <filename>

Add 指令的工作是根据用户给出的文件路径更新 Git 工作区的索引。

  1. 递归检索所有路径对应的文件,获取每个文件相对 Repo 根的路径
  2. 将这些文件路径添加到 Git 索引

第二步工作其实调用了 git update-index --add <file_name> 这个指令来完成。我们可以把 git update-index --add 看作私有指令,比 git add 多了一些限制,例如路径只能是文件而不能是目录。

3. git rm <filename>

Rm 指令的整体流程和 Add 指令很像,都是获取到对应文件的路径之后更新索引(所以 git rm 也调用了 git update-index),区别是 Rm 还需要能够删除文件本身。Rm 指令有几个参数值得一提:

  • -f:删除当前工作区内存在未提交的变更的文件,没有这个参数则不允许删除;
  • -r:删除目录下的所有文件;
  • --cached:删除文件的索引但不删除文件本身;

4. git commit -m <commit_message>

Commit 指令负责创建一个索引当前变更的 Commit 对象,并将其写入 objects 目录,然后将 HEAD 指针指向该 Commit。Commit 阶段会创建三类对象:Commit 对象、Blob/Tree 组成的索引对象、以及实际存储文件内容的 Blob 对象。Gitlet 中的流程如下:

  1. 将当前 Index 当中的所有文件路径及其 Hash 读取出来,写入到一个 TreeObject 中
  2. 获取当前的 TreeObject Hash,与 HEAD 指针指向的 Hash 对比,如果相同的话就不需要 Commit,直接退出
  3. 检查当前是否存在 Conflict,如果存在的话,直接退出
  4. 写入一个新的 Commit Object,包含 Commit Message 和 Parent Commit Hash
  5. 将 HEAD 指向新的 Commit
  6. 如果在 Merge 过程中,就终止 Merge

由于 Commit 不但可以直接调用,还会在 Merge 和 Rebase 等操作时被调用,所以其中需要多做一些关于 Merge 和 Conflict 的检查。

5. git branch <branch_name>

在 Git 当中,git branch 包含了很多和分支有关的操作。不过在 Gitlet 这个很简单的实现版本中,只包含了 listcreate 这两个最重要的功能,其流程如下:

如果没有传 branch_name 参数,列出 refs/heads/ 当中的所有分支;否则,创建一个新的分支,指向当前的 HEAD 位置。创建分支之后,还需要把这个分支的 HEAD 指向当前 Commit。

6. git checkout <branch_name|commit_hash>

git checkout 的主要功能是将当前 HEAD 指针指向某一个 Commit 或者 Branch,同样 Gitlet 也只实现了这个核心能力,没有实现其他参数功能。主要流程如下:

根据 Objects 中是否存在判断当前给的是 Hash 还是分支名,先将当前工作区的文件状态调整为目标 Commit,然后将当前 HEAD 指针修改为目标 Hash 或者分支名。

主流程之外还做了一些检查逻辑,包括要跳转的 Commit 是否存在,是否就是当前分支等。

7. git diff <ref1> <ref2>

在 Gitlet 中,git diff 的主要功能是对比两个 Commit 之间的文件差别。如果只传入一个 ref 或者不传,则输出与当前工作区内容的对比结果。这里简化了对比逻辑,只比较文件是否有改动,不展示实际的改动内容。

在了解 Git 的 Object 存储机制之后,我们可以想到一个简单的方法来获取 Diff 结果,也就是通过 BranchHead 找到 CommitObject,从 CommitObject 找到 TreeObject,最终定位到实际的 BlobObject。

如果是多个 Commit,就对比两者的 TreeObject 是否有区别,文件树中无法对应的文件就是添加或者删除,树中都存在的文件需要对比其 Hash 是否相同就知道是否存在变更了。

有个值得一提的点,Git(或者 Gitlet)的 Diff 都是在 ref1 的基础上对比 ref2,ref2 多的文件或者行就是“添加”,ref2 少的文件或者行就是“删除”,并不会对 ref1 和 ref2 做时间顺序的比较。

8. git remote add <remote_name> <remote_location>

Gitlet 只实现了 git remote add 命令,方法也很简单,只是负责将 remote 配置写入 config 当中,如果已经存在了同名 remote 就抛出异常。配置实例如下:

[remote "origin"]
  url = ../gitlet

9. git fetch <remote_name> <ref>

Gitlet 实现的 git fetch 指令非常简单,没有做复杂的按需逻辑,只是读取了 remote repo 的所有 Object,然后全量复制到当前 repo。fetch 操作不对本地的 ref 做修改,只更新 remote ref 和 FETCH_HEAD。

这里值得一提的是 FETCH_HEAD 指针,他存储的内容像这样:6f4c6b5b branch master of ../gitlet-test/ 。这个指针的作用是作为 Merge 的参考点。例如 pull 操作的本质是先 fetch 然后 merge FETCH_HEAD。

10. git merge <ref>

git merge 是一个相当复杂的操作,但表达的含义很清晰,就是讲指定 ref 位置的状态合并到当前 HEAD 指针位置。

Merge 会因为各种原因中止,但最重要的是不能影响到当前工作区内的文件,如果工作区的改动和 Merge 会发生的改动影响到了同一个文件,就会中止 Merge。

在 Merge 时,有一种特例是当前 HEAD 是合并目标的祖先节点,可以执行 FastForward 操作,也就是将当前索引、工作区更新为合并目标的状态,并将 HEAD 直接指向合并目标。

正常合并时,需要先创建一个 MERGE_HEAD 指针指向合并目标、一个 MERGE_MSG 文件,内容是标准的 Merge commit 信息;