前言

在国内的技术社区,几乎没有任何blog阐述过有关source move问题的解决方案。一般来说,受到这个问题困扰的技术人员还是比较少的,但是相信随着国内开源社区的不断壮大,遇到类似问题的人会逐渐变多,本文仅仅是抛砖引玉地给出了我现在采用的一种解决方案和最佳实践,核心侧重于提供思路。

Source move难在哪儿

如果你是一个公司内部项目的maintainer,而这个项目又需要开源的话,那么如何进行代码的内外同步就是一个令人头疼的问题。

这个问题之所以令人头疼,核心原因在于开源过程存在的一些限制和要求:

  • 公司内部的代码通常包含一些特殊的code,比如为公司某个产品专门设计的策略、内部管理使用的issue/jira/wiki link等内容,这部分code是无论如何不能泄漏出去的。
  • 因为git可以找回历史,所以开源出去的repo和公司内部的repo本质上还不是一个repo,最起码一部分的git commit object是不一样的
  • 内部的repo和外部的repo都需要进行迭代开发,所以在开发过程中保持多个repo的同步也是一个问题,不然一段时间之后就等着代码分叉吧…
  • git在commit object中保存的是全量文件,而不是增量更新(这一点很多人都会产生误解),所以除了filter-branch这类操作之外,很难做到内部仓库删除掉一些文件就成了开源版本的仓库

同步流程需要很容易集成进CI/CD中,尽量减少人力消耗

上面的限制,决定了: 用户需要在源代码层级上做代码的迁移。也就是对文件做一些读写操作,比如重新组织或者删除了内部的一部分code,就成了外部开源的code。

因为这个流程本身基本上是文件的读写和对git object的操作,所以想象中造一个轮子应该不存在本质难的问题。不过本着“现成的轮子能满足需求就不自己造”的原则,我还是简单调研了一些开源的工具。

Why copybara

github上能找到的现成的工具只有两个:fbshipitcopybara,FAIR下面很多知名的codebase比如pytorch、detectron2都是用fbshipit做代码的同步,而google的open source best practice中则提及了copybara这个工具。

fbshipit本身支持的迁移方式比copybara多了一了hg,这是对我来说唯一的好处。而缺点则比较多:可以参考的文档比较少(甚至现在master上的文档应该是n个版本之前的);配置文件使用hack(PHP的一个dialect);example简直约等于没有。总体上,感觉fbshipit更像是一个fb内部使用的服务,如果自己要搞会比较麻烦。

作为对比,copybara最大的缺点就是安装起来比较heavy,但是除此之外都要比fbshipit好得多:配置文件使用starlark(python的一个dialect);文档虽然不多,但是够用;公司内部有 MegEngine Bot 写的一些example作为参考。本着尽快上手的原则,就选择了copybara作为裁剪工具。

How to use copybara

copybara的本质是基于正则表达式做匹配,通过匹配规则来修改代码。所有对外的repo都需要有一个SoT(source of truth),也就是唯一的truth。当同步出现了问题,需要做判定以谁为准的时候,SoT就是标准答案。

考虑到一些可能的使用场景,我在本文的下个部分给出了一些实践中使用的transform,仅仅是提供一些参考,如果不是对细节很感兴趣的话可以直接跳过下个部分直接到best practice部分。而如果想要知道更细节的内容的话,可以参考这个手把手教你使用copybara的blog. 对code的transform

删除多行code

要删除多行code,就需要标记在何处开始,以及在何处结束。这里我们使用BEGIN/END-INTERNAL作为对应的标记。

# BEGIN-INTERNAL
internal_only_code()
# END-INTERNAL

下面这个是官方提供的一个example,需要注意的是:从re的规则来看,它会把BEGIN-INTERNAL标记之前的空行一并删除掉。

core.replace(
    before = "${x}",
    after = "",
    multiline = True,
    regex_groups = {
    	"x": "(?m)\\n*^.*BEGIN-INTERNAL[\\w\\W]*?END-INTERNAL.*$",
    },
)
删除单行code

实际当中只删除一行code的情况还是比较常见的,为了读起来友好一些,使用一个DELETE-THIS-LINE作为标记,code读起来像是下面这种

def f():
    x = "Hello"
    x += "world"  # DELETE-THIS-LINE
    return x

对应的transform example:

core.replace(
    before = "${line}",
    after = "",
    multiline = True,
    regex_groups = {
        "line": "(?m)\\n^.*?DELETE-THIS-LINE.*$",
    },
)
增加单行code

除了删除单行,我们还需要增加某些单个行,实际中类似:

def f():
    # ADD-THIS-LINE var = "Hello"
    pass

因为python中缩进是有语意的,所以我们在使用re进行匹配的时候,就需要考虑空格带来的影响。对应的transform如下:

core.replace(
	before = "${indent}${symbol}${code}",
    after = "${indent}${code}",
    regex_groups = {
    	"indent": "(?m)^\\s*",
        "symbol": "#.*ADD-THIS-LINE\\s*",
        "code": "\\S.*$",
    },
)
增加多行code

这个case有一些复杂,如果为了省事的话可以反向思考:只要能把多行的注释删除掉就行了。不过增加多行code的写法让原来的code显得很冗长,不是特别推荐。 具体的例子可以参考下面的example:

# BEGIN-INTERNAL
"""
# END-INTERNAL
external_only_code1()
external_only_code2()
# BEGIN-INTERNAL
"""
# END-INTERNAL
删除/移动文件

如果仅仅是删除文件,只需要在dest file list中使用exclude排除文件即可,而移动文件本身还是对应core中的一个操作,参考如下code:

core.move("foo/bar_internal", "bar")

处理外部PR

因为裁剪出去的code是开源的版本,自然免不了需要处理PR(Pull Request)的问题。可以预见,如果在外部的repo上合并了一个PR,就会和SoT原则发生冲突。而对于内部仓库使用copybara之后,就会强制覆盖外部的commit,相当于git push -f操作,这对于任何一个项目来说来说都是不能接受的。

官方推荐的流程其实是向下面这种,但是实际中我们采取了一个不太一样的解决方案。

  +--------------------+             +--------------------+
  |                    |             |                    |
  |  External Repo     |             |    External PR     +<---+ contributor
  |                    |             |                    |      opens a PR
  |                    |             |                    |
  +--------^-----------+             +--------+-----------+
           |                                  |
    New commits are                  Changes shadowed as an
    pushed via copybara              internal PR via copybara
           |                                  |
  +--------+-----------+             +--------v-----------+
  |                    |             |                    |
  |   Internal Repo    +<------------+  Internal PR       |
  |                    |   CI runs   |                    |
  |                    |   &         +--------------------+
  +--------------------+   Team member reviews and merges
patch integrate

我们第一个需要解决的问题是如何将外部PR引入到内部并且顺利裁剪。从功能上来说,copybara其实是支持从外向内的流程的(定义一个从外部向内部的workflow就可以了,也就是上图中的内容),但实际上engine组 @MegEngine Bot 已经趟出来一个更方便的方法:通过打patch(git format-patch)然后am(git am)的方式将外部的PR引入到内部的仓库中。因为外部文件本身是transform之后产生的,这个过程中会偶尔有一些conflict需要处理,不过总体来说不会有太大的问题,问题通常出现在integrate之后的对外裁剪过程。

如果PR的target branch已经包含了对应的commit,那么github/gitlab平台会自动标记PR为merged状态。但是判断两个commit是否相同的逻辑是commit object的sha1 hash是否相同,而这个hash由很多因素决定,比如source tree、commit message等(详情可以参考这个gist),而copybara在裁剪的时候会默认在commit message中生成GitOrigin-RevId(也就是对应的内部commit,参考下图),还会修改对应的时间戳信息,这就导致了commit的hash发生了变化。如果此时直接把裁剪后的branch push到github,PR就不会自动merge,但是PR中的文件diff已经没了。 ​

copybara commit message
fake merge

为了能merge一些外部的PR,copybara本身会根据特定的label判定commit是不是patch integrate(这个label默认是COPYBARA_INTEGRATE_REVIEW,详情参考文档),在commit message中包含了label的情况下就不会生成GitOrigin-RevId,同时会根据commit message自动merge对外的PR,这样相当于在原始commit后增加一个merge commit,在merge commit中会包含GitOrigin-RevId等内容,外部的branch就会包含PR的commit,PR也就会变成merged状态。

其他解决思路

特意去看了一下pytorch和detectron2处理外部PR的方法,发现对于外部提交,pytorch/detectron2全部都会close掉,之后由bot告知contributor对应的commit id。这就是因为引入了新的commit message和更改了commit时间戳,导致无法和外部commit hash对齐,只能全部close掉。这种做法的最大好处就是不会生成merge commit,整个source tree看起来就非常干净。

大概率会踩的坑

前面基本上把使用copybara的一些常用的方法介绍了一下,这里插一些集成copybara到CI/CD过程中遇到的坑,期望能够节省使用者的时间。

  • 在CI/CD中copybara如果没有上传成功,就会认为是异常退出(exit code为非0数值)。比如你重跑了一下workflow,job就会神奇地fail掉。在我第一次把copybara workflow加入到CI/CD中的时候,找了半天CI/CD异常退出的bug。最后为了让exit code为0,写成了如下形式:
copybara copy.bara.sky || echo "copybara failed"
  • 默认情况下,copybara裁剪的代码是从init commit到和git的远端同步的部分,所以当你本地commit了code后直接运行copybara并不能对代码进行裁剪。如果更新了sky文件但是不生效,多半也是因为忘了push到远端了。

吐槽

copybara基本上满足了我对于source move的诉求,但是在有些场景下,使用起来还是不太方便,所以作为用户,在这里小小吐槽一下(我看开发团队bandwidth不太够的样子,就不发issue骚扰了)

copybara本身基于re做匹配,这一点我认为是合理的,但是在处理匹配的代码的时候,完全可以做的更加动态一些。

考虑正常代码的裁剪过程,其遵循如下的一个模式:找到匹配的pattern -> 处理该pattern -> 返回处理后的结果。处理pattern的过程可能是很动态的,而且这一步本质上就是对于字符串的各种变换方式,应该允许用户自己使用函数定义,既然starlark本身就是python的一个dialect,那么在其中写一些python的处理逻辑也是很正常的诉求。

比如下面这种自定义transformation的写法:

def f(start, x, end):
    # transform code

core.dynamic_process(                                                   
    before = "${start}${x}${end}",
    regex_groups = {"start": start_regex, "x": x_regex, "end": end_regex},
    func = f,
)

best practice

在最后,结合engine团队的反馈和我自己的一些实践,给一些目前的best practice。

  • 尽可能做到仅仅需要删除内部独有的文件就可以让外部的code正常跑起来,这样copybara是配置起来最简单的,而且在code review的时候会少很多心智负担。如果能通过refactor把内部和外部的code区分的比较干净,本身也说明项目的复杂度相对比较低
  • 项目中copybara的标记过多是一个red flag,表明耦合度可能过高。如果一个文件中出现了过多的copybara中使用的标记,那么就应该考虑是否要将文件分拆。另外过多的copybara标记也更加可能导致开发人员贡献了一些code但是最终没有做code transform的现象,为后期工作埋雷。
  • 裁剪后的版本最好有一个单独的repo可以查看,这样release之前更容易发现问题,及时在内部修复。
  • 在CI/CD中最好diff一下move前后的内容,这一步也是为了给code review减少负担,防止reviewer在merge某个PR之后发现copybara做错了,再补交一个commit等类似现象等出现。

参考资料

  1. copybara github
  2. copybara reference
  3. starlark
  4. copybara intro
  5. copybara action
  6. open source best practice