表层
了解 Git 的基本使用之后,我们从表层一步步拆解他。Git 提供的接口是 CLI,那么就从这里开始。这里我的参考资料是 Gitlet,它的开篇 “Git in six hundred words” 是一些 Git 的简单使用说明,提到了以下这些常用命令,它们就是我们需要拆解的对象了。
git init
git add/rm <filename>
git commit -m <commit_message>
git clone <source_dir> <target_dir>
git remote add <remote_name> <target_dir>
git pull <remote name> <branch name>
git fetch <remote name> <branch name>
git merge <commit id>
git branch <new branch name>
git checkout <branch name>
git push <remote name> <branch name>
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 工作区的索引。
- 递归检索所有路径对应的文件,获取每个文件相对 Repo 根的路径
- 将这些文件路径添加到 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 中的流程如下:
- 将当前 Index 当中的所有文件路径及其 Hash 读取出来,写入到一个 TreeObject 中
- 获取当前的 TreeObject Hash,与 HEAD 指针指向的 Hash 对比,如果相同的话就不需要 Commit,直接退出
- 检查当前是否存在 Conflict,如果存在的话,直接退出
- 写入一个新的 Commit Object,包含 Commit Message 和 Parent Commit Hash
- 将 HEAD 指向新的 Commit
- 如果在 Merge 过程中,就终止 Merge
由于 Commit 不但可以直接调用,还会在 Merge 和 Rebase 等操作时被调用,所以其中需要多做一些关于 Merge 和 Conflict 的检查。
5. git branch <branch_name>
在 Git 当中,git branch
包含了很多和分支有关的操作。不过在 Gitlet 这个很简单的实现版本中,只包含了 list
和 create
这两个最重要的功能,其流程如下:
如果没有传 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 信息;