git submodule 和 git subtree,你会选择哪个来管理子项目?
前言
如果想在一个项目中用另一个项目的代码,你会怎么做呢?
有同学说,可以发一个 npm 包呀,然后在另一个项目里引入。
这样是可以,但是如果经常需要改动它的源码呢?这样频繁发包就很麻烦。
那可以用 monorepo 的形式来组织呀,也就是一个项目下包含多个包,它们之间可以相互依赖。
这样确实可以频繁改动源码,然后另一个包里就直接可用了。
但如果这个包是一个独立的 git 仓库,我希望它虽然在另一个项目里用了,但要保留 git 仓库的独立性呢?
这种就可以用 git submodule 或者 git subtree 了。这俩都实现了一个 git 项目里引入了另一个 git 项目的功能。
那 submodule 和 subtree 都能做这个,它俩有什么区别呢?我该用哪个好呢?
这篇文章我们就来详细对比下 git submodule 还有 git subtree。
准备环境
首先我们创建一个名为 git-research-child 的文件夹,然后初始化为 git 项目,并提交三次推送到远程,如下:

3 个 commit,每个文件一个 commit。
接着我们创建一个名为 git-research 的项目,并推送到远程。

接着我们需要在 git-research 项目里引入 git-research-child。 该怎么做呢?
submodule
我们先用 git submodule 的方式。
在 git-research 项目里执行:
git submodule add https://gitee.com/luxcurl/git-research-child.git child这个命令就是将 git-research-child 项目添加到 git-research 项目的 child 目录下:

然后我们再在 child 目录下再添加一个 git submodule,这个 git submodule 就是 git-research-child 项目本身:
cd child
git submodule add https://gitee.com/luxcurl/git-research-child.git child2现在就是两级 git submodule 了:

在 .gitmodules 里记录着它的 url(submodule 的远程 url)和保存的 path(submodule 的目录路径名):

前面说 submodule 能保留独立性,怎么看出来的呢?
首先,它有独立的 .git 目录,代表是单独 git 项目。

submodule 的 .git 目录其实是放在根 git 项目的 .git 下的:

这样就保证了它们依然可以独立的 pull 和 push。
比如我在 child 里加了一个 444.md 的文件:

然后你会发现 child 目录是可以单独 执行 git add、git commit、git push 的,它依然是独立的项目,父项目只是记录了它关联的 commit id 是啥。

这就是 submodule 的独立性,你可以在这个目录下单独执行 pull、push,单独管理变更,父项目只是记录关联的 commit 的变化。
那如果别人 clone 下这个项目来,还有这个 submodule 么?
我们 clone 下 git-research 项目试试:
git clone https://gitee.com/luxcurl/git-research.git
可以看到确实有 child 这个目录,但是没内容。
这是因为它需要单独初始化一下并更新下代码:
git submodule init
git submodule update或者执行:
git submodule update --init就可以看到代码被拉下来了:

但只有一层,如果想递归的 init 和 update,可以这样:
git submodule update --init --recursive这样它就会把每一层 submodule 都拉下来。
这样就完整下载了整个项目的代码。
当然,这一步可以提前到 git clone,也就是执行:
git clone --recursive-submodules https://xxx.xx这样就不用单独 git submodule init 和 git submodule update 了。
小结
小结下 git submodule 的用法:
- 通过 git submodule add 在一个项目目录下添加另一个 git 项目作为 submodule
- submodule 下可以单独 pull、push、add、commit 等
- 父项目只是记录了 gitmodules 的 url 和它最新的 commit,并不管具体内容是什么
- submodule 可以多层嵌套
- git clone 的时候可以 --recursive-submodules 来递归初始化 submodules,或者单独执行 git submodule init 和 git submodule update
可以体会到啥叫复用子项目代码的同时保留项目的独立性了么?
subtree
然后我们再来试试 git subtree。
还是这样一个项目:

我们用 subtree 的命令添加子项目:
git subtree add --prefix=child https://gitee.com/luxcurl/git-research-child.git master
这样和 submodule 有什么区别呢?
不知道你有没有发现,child 目录下是没有 .git 的,这代码它不是一个单独的 git 项目,只是一个普通目录:

所以你在这个目录下的任何改动都可以被检测到:

可以和整个项目一起 git add、commit、push 等。
不过 subtree 的方式在创建目录的时候会生成一个 commit:

那这样都作为一个普通目录了,这个子项目还独立么?还能单独 pull 和 push 么?
可以的!
虽然没有单独的 .git 目录,但它依然有独立性。你可以通过 subtree 的命令来 pull 和 push 它的代码。
比如我们先试试 pull。
我在 git-search-child 这个项目下加两个 commit:

加了 555、666 这俩 commit。
然后我在项目下执行 git subtree pull:
git subtree pull --prefix=child https://gitee.com/luxcurl/git-research-child.git master这样子项目的最新改动就拉下来了:

所以说 subtree 虽然把它作为普通目录来管理了,但它依然保留着独立 pull 和 push 到单独项目的能力!
上面的 url 如果你觉得敲起来麻烦,可以放到 git remote 里来管理:
git remote add child https://gitee.com/luxcurl/git-research-child.git这样就可以只写它的名字了:
git subtree pull --prefix=child child master这样 pull,会生成 3 个 commit,刚拉下来的 555、666 的 commit,还有一个 merge commit。
你也可以加个 --squash 来合并:
git subtree pull --prefix=child child master --squash这样就只有一个合并后的 commit,一个 merge commit 了。这就是 --squash 的作用。
再来试下独立的 push。
git subtree push --prefix=child child master这样就把它 push 上去了。
注意,这里可不是整个项目的 push,而是把那个子项目目录的改动 push 到了子项目里去。
另一个项目里就可以把它拉下来。
那问题来了,不是都没有 .git 目录了么?
那 subtree 是怎么知道哪些 commit 是新的,是属于这个子项目的呢?
还记得 subtree add 的时候单独生成了一个 commit 么?
git 会遍历 git log,直到找到这个 commit,然后把之间的 commit 里涉及到那个目录的改动摘出来,单独 push 到子项目。
最后,git submodule 在 clone 的时候需要单独拉一下子项目代码,那 git subtree 呢?
我们试试:
git clone https://gitee.com/luxcurl/git-research.git git-research-3可以看到,拉下来的就是全部的代码。
也就是说它真的就是个普通目录,只不过可以单独的作为子项目 pull 和 push 而已。
这就 git subtree 的使用方式。
小结
- git subtree add 可以在一个目录下添加另一个子项目
- 子项目目录和别的目录没有区别,目录下改动会被 git 检测到
- 可以用 git subtree pull 和 git subtree push 单独提交和拉取子项目代码
- git subtree pull 加一个 --squash 可以合并拉下来的 commit