这篇短文将介绍如何用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的实现还挺有用!