V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
git
Pro Git
Atlassian Git Tutorial
Pro Git 简体中文翻译
GitX
CodingNET
V2EX  ›  git

Git 客户端在 WebIDE 中的实现

  •  2
     
  •   CodingNET · 2016-05-12 17:15:13 +08:00 · 3005 次点击
    这是一个创建于 3123 天前的主题,其中的信息可能已经有所发展或是发生改变。

    Coding WebIDECoding.net 自主研发的在线集成开发环境 (IDE)。你可以通过 WebIDE 创建项目的工作空间, 进行在线开发, 调试等操作,有功能健全的 Terminal 。由于 Git 使用门槛偏高, WebIDE 提供了便利的 GUI 界面,在此前, WebIDE 实现了基本的 Git 客户端特性。本次更新,增加了 merge , stash , rebase , reset, tags 几个高级特性,使得开发者使用 WebIDE 的效率大大提升!以下为 Coding.net 工程师在实现 WebIDE 中 Git 功能的心得分享。

    版本控制

    管理文档、程序、配置等文件内容变化的的系统。

    其实版本控制很想 并不难理解,其实即使不是编程人员对他也不会陌生,比如 windows 的系统还原, mac 的 timemachine 。他们在某一时刻,记录下系统的状态或文件的内容,然后在需要的时候可以恢复。

    对于程序员来说,他有以下好处:

    1. 恢复:当不小心删除了文件、或者改错了文件,可以恢复文件内容

    2. 回滚:新版本出现了重大问题,可以回滚到上一正确的状态。

    3. 协作:不同开发者根据同一个版本进行开发,形成不同版本可以方便的合并在一起。

    常见的版本控制系统

    常见的版本控制系统有 CVS 、 SVN 、 Mercurial 、 Git 等。

    这四个版本控制系统可以根据对网络的要求分成两组,一组是 CVS 、 SVN ,一组是 Mercurial 、 Git 。

    • CVS 、 SVN :使用中央仓库,开发者需要从中央仓库中取出代码

    • Mercurial 、 Git :使用本地仓库,开发者可以本地开发

    第一组要求必须连到公司的网络才能办公,而第二组仓库在本地,意味着不用连接到公司的网络,进一步可以说是离线就可以办公。

    像 Git 、 Mercurial 这样的分布式的版本控制系统变得越来越流行,正在慢慢取代像 CVS 、 SVN “中央式“的版本控制系统。

    为什么选择 Git

    是什么原因让 git 从这么多的版本控制系统中脱颖而出呢?

    1. 本地提交: 这意味着无论你是在家里、还是地铁上都可以离线工作了,不需要连到公司的网络。

    2. 轻量级分支: git 的轻量级分支使得你可以快速的切换项目版本。这种特性在某些场景下特别重要,尤其是当我们正在开发过程中,突然发现一个紧急 bug 需要修复,我们可以快速切换分支,修复 bug 。

    3. 解决冲突方便: 正因为有轻量级的分支, git 也鼓励我们使用分支进行开发。但是当我们将分支合并到主干时,不可避免的会出现冲突,而 git 解决冲突的方式对用户非常的友好

    4. 有 Github 、 Coding 这样强大的代码托管平台支持: 在 Github 和 Coding 上有非常多的开源代码,而且这两个平台上的用户非常的活跃,使用 git ,有助于接触更多优秀的项目、优秀的开发者,对我们的成长有非常大的帮助。

    Git 原理

    例子

    一段经典的 git 操作。

    touch README.md
    git add README.md
    git commit -m "add readme"
    

    touch READEME.md 可以代表创建、修改文件操作
    git add README.md 表示将对文件的改动添加到暂存区 git commit -m "add readme"表示将改动提交到仓库

    这些我们都已经知道了,那么添加到暂存区、提交到仓库具体是什么意思?

    三种状态

    图片

    git 有三种状态:工作区、暂存区、本地仓库。

    • add: 工作区 -> 暂存区

    • commit: 暂存区 -> 本地仓库

    工作目录我们是知道的,我们平时编写代码,就是在工作目录中完成的。

    暂存区也叫做索引,保存了下次将提交的文件列表。

    本地仓库是 Git 用来保存项目的数据的地方。提交代码,意味着将文件内容永久保存到数据库中。

    首先看一下本地仓库,项目中的文件在本地仓库中是以快照的形式来保存的。

    git 中的快照

    图片

    每一个 version ,都是项目的一次完整快照。而快照中没有修改的文件, Git 使用链接指向之前存储的文件。

    这就带来了一个问题,链接是什么?怎么快速的知道文件内容是否发生了改变? git 中的方案是使用 SHA-1 。

    SHA-1

    echo 'test content' | git hash-object --stdin
    d670460b4b4aece5915caf5c68d12f560a9fe3e4
    

    特点:

    • 由文件内容计算出的 hash 值
    • hash 值相同,文件内容相同
    • 作为唯一 id

    SHA-1 将文件中的内容通过算法生成一个 160bit 的报文摘要,即 40 个十六进制数字。 SHA-1 的一个重要特征就是几乎可以保证,如果两个文件的 SHA-1 值是相同的,那么它们确是完全相同的内容。

    上面的代码,无论运行几次,得到的 hash 值都是一样的。这个 hash 值可以看作是该文件的唯一 id 。

    Git 中所有数据在存储前都计算该 hash 值,然后用该 hash 值来引用。因此这个 id 除了可以唯一表示任何版本中的文件,还可以表示任何一次提交、任何一次代码的快照。

    实际存储在 git 中的数据

    find .git/objects -type f

    图片

    我们来看一下实际存储在 git 中的数据,看起来比较乱,这些数据存放在 .git/objects ,然后使用 sha-1 计算的 hash 值的前两位作为文件夹的名字,后面的 38 位作为文件的名字。

    在这么多的文件中,其实可以分为 4 种类型,分别是 blob 、 commit 、 tree 和 tag 。

    将上面的内容经过按照这些类型整理可以得到类似下面的关系(忽略 tag )。

    图片

    每一个线框表示了一个 object ,也就是 objects 目录下的一个文件。

    每个 object 上面的这个字母与数字组合的字符串,就是 object 的上一目录名+文件名,也就是 sha-1 hash 值。

    每个 object 的第一行格式是一致的,都由两列组成,第一列表示了 object 的类型,第二列是文件内容的长度。

    接下来我们分别看一下每种类型:

    blob: 用来存放项目文件的内容,项目的任意文件的任意版本都是以 blob 的形式存放的。但是不包括文件的路径、名字、格式等其它描述信息。

    tree: 用来表示项目中的目录,我们知道,目录中有文件、有子目录。因此 tree 中有 blob 、子 tree 。这是与目录的对应。 tree 中还包含了文件的路径以及名称。从顶层的 tree 纵览整个树状的结构,叶子结点就是 blob ,表示文件的内容,非叶子结点表示项目的目录,那么顶层的 tree 对象就代表了当前项目的快照

    commit: 一个 commit 表示一次提交。里面的 tree 的值指向了项目的快照。还有一些其它的信息,比如 parent , committer 、 author 、 message 等信息。 tree 看成一个树状的结构, blob 可以作为其中的叶子结点出现。 commit 可以看作是一个 DAG ,有向无环图。因为 commit 可以有一个 parent ,也可以有两个或者多个 parent 。

    至此,本地仓库我们就了解完了。接下来看一下暂存区。

    暂存区

    暂存区是工作区与本地仓库之间的一个缓冲,它保存了下次将提交的文件列表信息。它其实是一个文件,路径为: .git/index。由于该文件是一个二进制文件,没办法直接看它的内容,但是可以使用 git 命令查看。

    图片

    每列的含义依次为,文件权限、文件 blob 、文件状态、文件名。

    第二列指的是文件的 blob 。这个 blob 存放了文件暂存时的内容。

    我们操作暂存区的场景是这样的,每当编辑好一个或几个文件后,把它加入到暂存区,然后接着修改其他文件,改好后放入暂存区,循环反复。直到修改完毕,最后使用 commit 命令,将暂存区的内容永久保存到本地仓库。

    这个过程其实就是构建项目快照的过程,因此可以说暂存区是用来构建项目快照的区域。

    分支

    接下来看一下分支的概念,首先看一张图:

    图片

    这张图中的每一个点表示了一个 commit 。从这张图中我们可以看出的信息有:

    • 从任意一点分歧出来的线都可以叫做分支

    • 分支可以合并

    分支的实现

    在 .git/HEAD 文件中,保存了当前的分支。

    cat .git/HEAD
    =>ref: refs/heads/master
    

    其实这个 ref 表示的就是一个分支,它也是一个文件,我们可以继续看一下这个文件的内容:

    cat .git/refs/heads/master
    => 2b388d2c1c20998b6233ff47596b0c87ed3ed8f8
    

    可以看到分支存储了一个 object ,我们可以使用 cat-file 命令继续查看该 object 的内容。

    git cat-file -p 2b388d2c1c20998b6233ff47596b0c87ed3ed8f8
    => tree 15f880be0567a8844291459f90e9d0004743c8d9
    => parent 3d885a272478d0080f6d22018480b2e83ec2c591
    => author Hehe Tan <[email protected]> 1460971725 +0800
    => committer Hehe Tan <[email protected]> 1460971725 +0800
    => 
    => add branch paramter for rebase
    

    从上面的内容,我们知道了分支指向了一次提交。为什么分支指向一个提交的原因,其实也是 git 中的分支为什么这么轻量的答案。

    因为分支就是指向了一个 commit 的指针,当我们提交新的 commit ,这个分支的指向只需要跟着更新就可以了,而创建分支仅仅是创建一个指针。

    至此 git 的原理就讲完了,接下来看一下 JGit 。

    JGit

    JGit 是一个用 Java 实现的比较健全的 git 实现, Eclipse IDE 中的 git 插件 Egit ,就是基于 JGit 开发的。同 git 一样,它提供了底层命令和高层命令。

    图片

    高层命令的入口是 Git 类。高层命令好理解,我们使用 git 的客户端绝大多数命令都是高层命令。

    比如 add 、 commit 、 checkout 等都是高层命令,他们提供了友好的交互,往往一条命令就能完成你所想要的效果。

    底层命令的入口是 Repository 类。底层命令不同于高层命令,它们直接作用域 仓库( Repository )。比如 AbstractTreeIterator ,就是用来遍历 Tree 结构的, DirCache 是用来操作暂存区的, RevWalk 是用来遍历 commit 的, ObjectInsert 是用来生成 obj 的, ObjectLoader 是用来加载 object 。

    一条高层命令往往是由多条底层命令组成的。

    Repository(仓库)

    作为一切的开始,你需要一个 Repository 。

    Repository repository = new FileRepositoryBuilder()
                    .setGitDir(new File("/home/tan/GitTest/.git"))
                    .readEnvironment()
                    .build();
    

    使用时只需要将仓库的路径传进来就可以了,它会自动读取一些必要的环境变量。

    ObjectInserter

    ObjectInserter 用来将数据插入到 git 数据库中,也就是 objects 目录下。插入的类型为我们刚才提到的四种,分别是 Blob 、 Tree 、 Commit 、 Tag 。

    try (ObjectInserter inserter = repo.newObjectInserter()) {
        ObjectId objectId = inserter.insert(Constants.OBJ_BLOB, 
            new String("test").getBytes());
        inserter.flush();
    }
    

    第二个参数表示要插入的数据,该数据会自动使用 zlib 压缩。

    TreeWalk

    用来遍历目录结构,可以为工作区、暂存区或项目快照(版本库)。

    
    try (TreeWalk treeWalk = new TreeWalk(repo)) {
        treeWalk.setRecursive(true);
    
        treeWalk.addTree(new FileTreeIterator(repo));
        treeWalk.addTree(new DirCacheIterator(repo.readDirCache()));
    
        while (treeWalk.next()) {
            AbstractTreeIterator treeIterator 
                    = treeWalk.getTree(0, AbstractTreeIterator.class);
            DirCacheIterator dirCacheIterator 
                    = treeWalk.getTree(1, DirCacheIterator.class);
    
        }
    }
    
    

    TreeWalk 是用来遍历树这种结构的,它比较厉害的一点是可以同时遍历多棵树,遍历多课树的思路为将文件列表做一个合并,然后遍历这个列表,没有的调用 getTree 会返回 null 值。

    其实 git status 就是这种原理来做的:

    1. changed : 在版本库、 idnex 中都存在,内容不同
    2. removed : 在版本库存在,在 index 不存在
    3. added :在 index 存在,在版本库不存在
    4. untracked :不在版本库和 index ,只在工作目录中存在
    5. modified :在 index ,在工作区,且文件内容不同
    6. missing :在 index 存在,在工作区不存在

    RevWalk

    RevWalk 用来遍历 Commit 。

    
    try (RevWalk revWalk = new RevWalk(repository)) {
        revWalk.markStart(one);
        revWalk.markStart(two);
    
        revWalk.setRevFilter(RevFilter.MERGE_BASE);
    
        RevCommit base = revWalk.next();
    }
    
    

    我们这个例子,标记了两个 commit ,我们设置的 filter 是 MERGE_BASE, 它会自动查找这两个 commit 所在分支的 MERGE_BASE 。其中 MERGE_BASE 可以看作是分支的分岔点,合并的时候 MERGE_BASE 会作为参照。

    使用底层命令

    高层命令其实是由多条底层命令组成的,比如我们最常使用的 add 、 commit :

    • add

      • 使用 ObjectInserter 将文件内容写入 objects (blob),得到 blob id
      • 使用 DirCache 将 blob id 写入暂存区
    • commit

      • 使用 DirCache 将 index 生成 tree
      • 使用 ObjectInserter 将 tree 写入仓库(tree),得到 tree 的 id
      • 构建 commit ,写入 tree id 以及设置其 parent 、 message 等其它信息
      • 利用 ObjectInserter 将 commit 写入 objects (commit),得到 commit id
      • 将 commit id 写入当前的 branch ,使得 branch 指向最新的 commit

    高层命令

    上面的复杂操作,可以简单的用底层命令替代。

    git.add().addFilepattern("README").call();
    git.commit().setMessage("add readme").call();
    
    

    高级操作的局限

    高层命令使用起来方便,但是它所提供的功能有限。这里我们拿 merge 举例。

    使用 JGit Merge api

    使用 JGit 提供的接口进行 merge 十分的方便,只需要指定要合并的 branch 就可以了。

    MergeCommand merge = git.merge();
    merge.include(branch);
    MergeResult result = merge.call();
    

    但是 merge 之后呢,文件冲突了怎么办,怎么解决冲突呢?实际上除了 merge , stash 、 rebase 等等操作也都会产生冲突。也就是说 git 冲突文件的处理是客户端的重要功能之一。

    遗憾的是 jgit 并没有提供解决冲突的方案,所以这就需要我们自己来解决这个问题。

    resolve conflicts:

    一种比较理想的解决冲突的方案是,将冲突的文件根据本地修改、基础版本、要合并分支的修改分成三栏。

    图片

    通过这样的方式,我们可以直观的对照冲突的内容,并且可以方便的选取或者要抛弃修改。

    可选方案

    1. 计算 merge base

      第一个就是计算这两个分支的 MERGE_BASE 。这样我们获得了三个 commit ,每个 commit 都都纪录了提交时的文件快照。而我们只要将冲突文件的内容从快照中取出来就好了。但是这个方案有个缺点,那就是我们只有在合并的那一瞬间才能知道要合并的分支,之后想要知道只能去 .git 下面的 MERGE_HEAD 去查,而且其它方式比如 stash 、 rebase 等操作引起的冲突是不会生成该文件的。

    2. 使用暂存区的信息

      想想我们 当我们有合并冲突状态时,使用 git status ,会列出冲突文件,以及冲突的类型,比如 “双方修改”、“由我们删除”,“双方添加”等这样的字眼, git 如果获得这些信息的呢?

      如果存在冲突文件,我们查看暂存区,可以看到类似下面的内容:

      git ls-files --stage
      100644 6e9f0da13f19b444ec3a9c3d6e795ad35c0554a2 1	Readme
      100644 29d460866c44ad72cc08ef4983fc6ebd48053bab 2	Readme
      100644 12892f544e81ef2170034392f63c7fc5e6c6ccd9 3	Readme
      

      原来暂存区中有四种状态用于标示文件:
      * 0: standard stage * 1: base tree revision * 2: first tree revision (usually called "ours") * 3: second tree revision (usually called "theirs")

      接下来我们专门看一下这 4 种状态是如何表示冲突状态的。

    文件冲突的状态:

    假设当前我们处于 master 分支,要合并的分支为 test ,开发历史如下图:

    图片

    现在假设合并过程中有个文件( Readme )发生了冲突,我们查询暂存区该文件的状态(可以有多个):

    • 1 and 2: DELETED_BY_THEM;
    • 1 and 3: DELETED_BY_US;
    • 2 and 3: BOTH_ADDED;
    • 1 and 2 and 3: BOTH_MODIFIED

    我们拿第一种情况举例,文件( Readme )有两种状态 1 和 2 , 1 表示该文件存在于 commit 1 (也就是 MERGE_BASE ), 2 表示该文件在 commit 2 ( master 分支)中被修改了,没有状态 3 ,也就是该文件在 commit 3 ( test 分支)被删除了,总结来说这种状态就是 DELETED_BY_THEM 。

    可以再看一下第四种情况,文件( Readme )有三种状态 1 、 2 、 3 , 1 表示 commit 1 ( MERGE_BASE )中存在, 2 表示 commit 2 ( master 分支)进行了修改, 3 表示( test 分支)也就行了修改,总结来说就是 BOTH_MODIFIED (双方修改)。

    获取冲突文件的三个版本

    知道了冲突文件的状态,就能在暂存区获得冲突文件的三个版本了。代码如下:

    DirCache dirCache = repository.readDirCache();
    
    // 在暂存区中,所有文件是按照字母顺序排列的,因此文件的不同状态是连着的
    int eIdx = dirCache.findEntry(path);
    // nextEntry 会自动调过文件名相同的文件,找到下一个文件。
    int lastIdx = dirCache.nextEntry(eIdx);
    
    // 在 [eIdx, lastIdx) 区间的也就是文件的冲突的不同版本
    for (int i=0; i<lastIdx - eIdx; i++) {
        DirCacheEntry entry = dirCache.getEntry(eIdx + i);
        
        // 如果是 MERGE_BASE
        if (entry.getStage() == DirCacheEntry.STAGE_1) 
            readBlobContent(entry.getObjectId());
        // 如果是 当前分支
        else if (entry.getStage() == DirCacheEntry.STAGE_2) 
            readBlobContent(entry.getObjectId());
        // 如果是 要合并的分支
        else if (entry.getStage() == DirCacheEntry.STAGE_3) 
            readBlobContent(entry.getObjectId());
    }
    

    至此我们得到了解决合并冲突的一个方案。

    Happy Coding;)

    5 条回复    2016-05-12 19:07:15 +08:00
    menc
        1
    menc  
       2016-05-12 18:13:38 +08:00
    感谢分享
    hanxiV2EX
        2
    hanxiV2EX  
       2016-05-12 18:19:40 +08:00 via iPhone
    挺好的
    EPr2hh6LADQWqRVH
        3
    EPr2hh6LADQWqRVH  
       2016-05-12 18:33:29 +08:00
    涨姿势了。。
    7jmS8834H50s975y
        4
    7jmS8834H50s975y  
       2016-05-12 18:39:08 +08:00 via Android
    不如 github 干净,打开后广告满天飞
    iyaozhen
        5
    iyaozhen  
       2016-05-12 19:07:15 +08:00
    感谢分享, Coding 还是很不错的。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2923 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 32ms · UTC 02:45 · PVG 10:45 · LAX 18:45 · JFK 21:45
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.