这篇短文将介绍如何用 500 行的 Javascript 代码,写一个你自己专属的 GIT。这不是一个如何使用 GIT 的工具,而是 GIT 的底层实现。目的是希望能加深对 GIT 的底层实现原理,而不是想换掉 GIT,这只是一个 GIT 的雏形而已代码来自开源,也回流开源,有需要且不嫌弃的可以上去看看 https://github.com/notechsolution/gitdou
缘起
跟 GIT 的结缘开始于 2011 年,公司决定不用原来的 IBM Clearcase,改用开源的 GIT。作为当时 GIT 的内部 support,确实有很长一段时间跟它厮混在一起。后来还写了几篇如何使用 GIT 的文章,有空可以翻翻 GIT 七年之痒. 前两年回一下炉,又写了几篇 GIT 入门.
最近看到一个叫 Richard Feynman 的人说过这么一句话
What I cannot create, I do not understand – Richard Feynman
嗯嗯,有点意思,扒拉了一下,还有不少人用 Javascript 写 GIT。这次的实现主要也是参考了其中一个叫 gitlet 的
用什么锤子?| 技术栈
GIT 是 Linux Torvalds 用 C 语言写的。小的不才不懂 C,那就用 Javascript 写写吧,ES6 可以让代码可以写得比较简洁。既然重造轮子,那就尽量少用框架吧。但是作为 lodash 粉,还是忍不住了,最后还是用了 lodash~~~.
当然,Pivotal Lab 中毒较深,做个练习也离不开 TDD,所以这次也用了 Ava 作为 testing 框架。但功力尚浅,有些 case 也偷懒了,testcase 跟代码的函数比例只做到 1:1,500 行的代码只有 500 行的 unittest。
锤出个什么东东?| 实现哪些功能
这次的目的是为了加深对 GIT 底层实现原理的理解,而不是做出一个真正的产品出来,所以对于用户操作没有做出各种友好的提醒,比如没有像 Already up to date 这样的提醒等等,只要实现了 GIT 的如下核心命令:
init
add
rm
commit
checkout
branch
remote
fetch
merge
pull
push
咋锤的?| 实现过程
下面尝试逐一来解释一下每个命令是干什么的。
gitdou.init
首先是初始化一个 GIT 的项目。GIT 在某种程度上可以理解为一个文件的数据库,里面保存着所有文件的所有版本。初始化的过程也就是创建各个文件以及目录.
.gitdou
├── HEAD
├── config
├── objects
└── refs
├── heads
.gitdou: 当前 repository 的根目录
config: 当前 repository 的配置文件,记录当前 repository 的各种配置,比如是不是 bare,远程协作仓库地址等等
HEAD: 存放 repository 指向哪个 branch,由于初始 branch 为 master,所有 HEAD 的初始值一般为 ref: refs/heads/master
objects : 存放数据库文件的目录
refs:存放 local branch 或者 remote branch 的当前 commit,类似于数据库的游标
初始化的过程就是在指定的目录.gitdou 下生成这些目录及文件的过程。代码就比较简单,根据目录结构,生成对应的文件树:
init: () => {
const gitdouStructure = {
HEAD: ‘ref: refs/heads/master’,
objects: {},
refs: {
heads: {}
},
config: JSON.stringify({core: {bare: false}}, null, 2)
}
files.writeFilesFromTree({‘.gitdou’: gitdouStructure}, process.cwd());
},
add
前面说到了 git 实际是一个数据库,存放了所有文件的所有历史版本。为了更方便高效地查询,数据库都会建立索引。git 也不例外,它也有一个 index 文件,记录所有文件的路径,这些文件的状态以及当前版本的 hash 值。
add 命令就是将指定路径的所有文件的路径,状态以及当前的 hash 值记录保存到 index 文件里面。其实现过程就是扫出指定目录下的所有文件,逐一计算他们的 hash 值,然后写到 index 文件里面
add: path => {
const addedFiles = files.listAllMatchedFiles(path);
index.updateFilesIntoIndex(addedFiles, {add: true});
}
rm
有添加命令,对应的也就应该有删除命令。其过程跟 add 基本一致,只不过多了一步把要删除的文件从当前 workingCopy 里面删除掉。
rm: path => {
const deletedFiles = files.listAllMatchedFiles(path);
index.updateFilesIntoIndex(deletedFiles, {remove: true});
files.removeFiles(deletedFiles);
}
commit
当任务已经到一段落,我们需要给当前版本做一个快照,方便以后找回。这时我们可以做一个 commit。这个 commit 将会包含一个 hash 树,这棵树将当前版本的所有文件连起来。当然还包含了一些 commit 的 metadata,比如谁,什么时候 commit,commit 的备注是什么等等。
具体实现大致为:
创建一个 hash 树,将所有文件连起来,并且保存到 objects 数据库里面
创建一个 commit 对象,包含 hash 树的 hash,commit 的消息,commit 的时间,如果有父亲 hash,也包含进来。同样将这个 commit 的对象保存到 objects 数据库里面
更新当前 branch,指向新的 commit hash
commit: option => {
// write current index into tree object
const treeHash = gitdou.write_tree();
// create commit object based on the tree hash
const parentHash = refs.getParentHash();
const commitHash = objects.createCommit({treeHash, parentHash, option});
// point the HEAD to commit hash
refs.updateRef({updateToRef: ‘HEAD’, hash: commitHash})
}
branch
GIT 的分支管理是可能稍微复杂一些,不同公司,不同的开发模式会有不同的分支管理,甚至有人将这个上升到分支管理的艺术的高度。最有名的分支管理模型应该就是 A successful Git branching model
但 … 但 … branch 在 GIT 的实现里面可以说是最最简单的一个了,所谓创建 branch 就是在.gitdou\refs\heads 创建一个用 branch 名字命名的文件,文件的内容就是当前的 hash. 突然想起某学习机广告:SO EASY~~~
branch : (name, opts) => {
const hash = refs.hash(‘HEAD’);
refs.updateRef({updateToRef:name, hash});
},
checkout
不能都是那么容易的啦!要不也不用花这么多时间写!checkout 就稍复杂一些。checkout 有点类似于还原现场. 将当前 workingCopy 还原成指定 commit 或者 branch 对应的工作环境。
前面 commit 命令的时候说到:创建一个 hash 树,将所有文件连起来,并且保存到 objects 数据库里面。所有首先我们要找出指定 commit 或者 branch 的 hash 树。再找出当前代码库版本的 hash 树。然后站在当前代码库 hash 树的角度,比较这出哪里改了,哪里删了,哪里新增的。最后将这些不同落实到当前代码库中。当然,别忘了更新 HEAD 文件指向 checkout 的 commit 或者 branch
checkout: (ref) => {
const targetCommitHash = refs.hash(ref);
const diffs = diff.diff(refs.hash(‘HEAD’), targetCommitHash);
workingCopy.write(diffs);
refs.write(‘HEAD’,`ref: ${refs.resolveRef(ref)}`);
}
remote
上面的这些命令基本都是在本地自己玩而已,后面这几个命令就涉及到跟其他人协作了!不过为了简单,协作也是通过文件系统操作而已,没有经过 http,但是原理基本一样!
remote 命令只要是用来管理有远程代码库的配置信息,GIT 里面 remote 命令实现了很多子命令,比如有 remote ls,remote show,remote add,remote remove。我们这里只实现刚需的 add 命令
remote add 命令将会读出代码库的配置文件.gitdou\config,然后在里面添加 remote 的属性
remote : (command, name, path) => {
const cfg = config.read();
cfg[‘remote’] = cfg[‘remote’] || {};
cfg[‘remote’][name] = path;
config.write(cfg);
},
添加后的.gitdou\config 文件内容大致如下(这里采用的是 JSON 格式存取)
{
“core”: {
“bare”: false
},
“remote”: {
“origin”: “git@github”
}
}
fetch
remote 已经准备好了,接着我们可以拉取其他人的代码库了!在真正 GIT 的实现中,这时就涉及到跟 GIT 服务器交互的细节,不过我们这里都是在本地,所有情况比较简单。
首先我们要在 remote 的工作目录下面,读取他 objects 数据库的所有对象,然后将这些对象写到我们的 objects 数据库里面,再将最新的 hash 更新到 refs/remotes/origin/${branch}
fetch : (remote, branch) => {
const remoteUrl = config.read()[‘remote’][remote];
const remoteHash = refs.getRemoteHash(remoteUrl, branch);
const remoteObjects = refs.getRemoteObjects(remoteUrl);
_.each(remoteObjects, content => objects.write(content));
refs.updateRef({updateToRef:refs.getRemoteRef(remote, branch), hash:remoteHash});
refs.write(“FETCH_HEAD”, `${remoteHash} branch ‘${branch}’ of ${remoteUrl}`);
return [“From ” + remoteUrl,
“Count ” + remoteObjects.length,
branch + ” -> ” + remote + “/” + branch].join(“\n”) + “\n”;
}
merge
fetch 的确是拿到了对方的所有对象,但是本地的代码丝毫没有变化,因为还没有将这些合并到我们的代码库里面。merge 做的就是这事。
这个版本我们只实现了没有冲突的场景,也就是可以 fastforward 的情况。
首先我们拿到 remote 的 hash 树,再读取我们当前的 hash 树,然后判断是否可以 fastforward (也就是判断 remote 是否包含了我们最新的代码),然后跟 checkout 类似,站在当前代码库的角度,找出两颗 hash 树的异同点,将这些异同点写到当前代码库。最后更新当前代码库的当前 branch,指向最新的 commit
merge: (ref) => {
const receiverHash = refs.hash(‘HEAD’);
const giverHash = refs.hash(ref);
if(merger.canFastForward({receiverHash, giverHash})){
merger.writeFastForwardMerge({receiverHash, giverHash});
return ‘Fast-forward’;
}
return ‘Non Fast Foward, not handle now’;
}
pull
有了 fetch 跟 remote 命令,pull 就躺着数钱了!因为 pull(remote, branch) = fetch(remote, branch) + merge(‘FETCH_HEAD’)
pull: function(remote, branch) {
gitdou.fetch(remote, branch);
return gitdou.merge(“FETCH_HEAD”);
}
push
来而不往非礼也!有 pull 也得有 push。push 的实现原理有点粗暴!直接跳转到对方的工作目录下,然后把自己的 objects 里面的所有对象写到对方的代码库里面, 再帮对方更新对方的 branch 引用!细思极恐,好在真正的 GIT 不是这样处理的!
push: ref => {
const onRemote = util.onRemote(remoteUrl);
const remoteUrl = config.read()[‘remote’][ref];
const receiverHash = onRemote(refs.hash, ref);
const giverHash = refs.hash(‘HEAD’);
objects.allObjects().forEach(item => onRemote(objects.write, item));
onRemote(gitdou.updateRef, refs.resolveRef(ref), giverHash);
}
结语
从有用的角度看,这次 GITDOU 的实现并无卵用!
从无用的角度看,这次 GITDOU 的实现还挺有用!