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


二牛
24 声望4 粉丝