上篇写了单仓和 Submodule:自己团队的代码往一个仓库里收,要挂别人维护的独立仓库才用 Submodule。发完就有人来问我:那 Git Subtree 呢?
他是看到网上不少文章说 subtree 是 submodule 的「替代品」,正纠结要不要迁过去。我跟他讲,这俩不是谁替代谁,是两条相反的路。Submodule 是「引」:把别人的仓库挂进来,代码还留在外面,主仓只记一个引用;Subtree 是「抄」:把外部仓库的代码整个抄进你的仓库,抄完就是普通文件,但留着一条跟上游同步的路。一个是请租客住进来,一个是把东西搬进自己家,怎么替代?
先弄清它俩分别是啥
Git Submodule:主仓库只记两样:子模块的远程地址,和一个固定的 commit 哈希(gitlink 引用)。子模块目录拉下来是完整的独立仓库,有自己的历史、分支、标签,代码并不真的躺在主仓里,真身还在它自己的仓库。
Git Subtree :一条命令把上游仓库的提交历史接进当前仓库的某个子目录(命令下一节统一给)。接进来之后目录就是普通目录:文件在主仓历史里有真实的提交记录,clone 一步到位,日常改完直接 add/commit,没有第二套版本体系。主仓和上游之间靠一次特殊合并提交维系,提交信息里带着 git-subtree-dir: libs/util 标记。
还得先澄清一件事:git subtree 不是 Git 亲儿子。它是 Avery Pennarun 2009 年写的脚本,后来被收进 Git 官方源码树的 contrib/subtree/ 目录跟着一起分发。Git for Windows、Homebrew、Ubuntu 这些常见环境都把它捆绑好了,所以多数人没意识到它不是原生功能;个别精简环境(Alpine 的 Docker 镜像、源码编译的 Git)没带它,会报 git: 'subtree' is not a git command,装个 git-subtree 包就行。而 git submodule 才是 Git 核心自带的能力,不存在「没捆绑」这回事。
关键差异,一条条过
克隆
- Submodule:clone 完主仓还得再拉一次子模块(
git submodule update --init),这一步忘了,子目录就是空的。 - Subtree:一次
git clone全部到手,子目录天然有内容。
日常改动
- Submodule:两步。先进子模块提交推送,再回主仓更新引用,漏一步就乱。
- Subtree:目录里的文件就是普通文件,改完直接 add/commit,无感。
和上游的来往
- Submodule:拉上游更新只是挪一下指针(
git submodule update),代码不进主仓;本地改完想回给上游,直接在子仓库里 push,天生支持。 - Subtree:拉上游要把对方的新提交并入本地(
git subtree pull);本地改动想回推给上游最绕,得先git subtree split把目录历史拆成独立分支再推,容易翻车。
历史与体积
- Submodule:主仓永远很小,历史都留在各子仓里。
- Subtree:上游历史跟着并进主仓,不带
--squash,主仓会被迅速撑大。
权限
- Submodule:子模块是独立仓库,权限在各自的 Git 服务端设,能按仓库管。
- Subtree:跟着主仓走,只有仓库级权限,管不到某个目录。
绕回来看,两边差别都来自同一个取舍:Submodule 保「独立」,宁可操作繁琐也要让子仓库能各自演进;Subtree 求「融入」,宁可历史混进来也要让目录跟主仓自己长的一样。跨仓库的联动改动两边都不顺手,这是各自的选择直接换来的。
Subtree 日常就三条命令
比 Submodule 多的,是一个 push 方向。核心都在 git subtree 下面:
首次挂入:
css
git subtree add --prefix=libs/util <地址> main --squash
上游更新了,拉下来合并:
css
git subtree pull --prefix=libs/util <地址> main --squash
本地改完想回给上游:
css
git subtree push --prefix=libs/util <地址> main
--squash 建议始终带上:它把上游的历史压成主仓里的一个提交。不带也行,但上游从创始以来的每一条 commit 都会混进你的主仓历史,仓库体积和 log 都被撑大,回推时也没多大帮助。
什么时候用哪个
Submodule 适合:主项目要集成外部独立维护的仓库,权责清晰、基本只拉不改。物理隔离是第一诉求,宁可两步操作也认。它回推本地改动最简单,直接在子仓库 push,这是 subtree 给不了的。
Subtree 适合:想把第三方或兄弟团队的代码抄进来自己改着用,还想偶尔跟上上游。典型场景是「fork 上游自己魔改,又不想彻底断奶」:改完自己的,隔段时间 pull 一次跟上游同步;本地改得差不多了,split 拆出分支回推给上游。团队不想学 Submodule 那套两步操作的,subtree 无感得像普通目录,上手成本低。
自己团队天天一起改的代码,还是回单仓,理由上篇说过,不展开。
这些坑我都踩过
第一个坑是历史膨胀。我头一回用 subtree 拉一个上游库进来,忘了带 --squash,几 MB 的库带进来几万条 commit,主仓 log 一翻全是别人的提交,克隆也变慢了。从那次起我每次 add 都条件反射带上 --squash。
第二个坑在回推。subtree 靠提交信息里的 git-subtree-dir 标记反推历史,本地一旦对这些提交做过 rebase、amend、filter 之类的改写,标记就丢了,split 出来的历史会缺料甚至推不动。现在我要回推,都先 git subtree split --prefix=libs/util -b 临时分支 检查一遍,再推这个分支。
第三个坑是两头同时改。本地改了 util,上游也改了 util,pull 时冲突得手工解;解完想再回推,冲突的合并提交又让 split 更难还原。subtree 适合「一头动得多、另一头动得少」的单向场景,两头高频对改,迟早得拆成独立仓库。
第四个坑,别把它当同步银弹。subtree 的本质是「抄一份进来 + 有方向地同步」,不是两个仓库间的实时双向镜像。真要两个团队并行高频协作一个组件,老老实实独立仓库 + 版本发布,比任何嵌法都省心。
最容易弄混的一处
「Subtree 里的目录」和普通目录长得一模一样,clone 完都是普通文件夹。这是它好用之处,也是最容易埋雷的地方:新同事根本看不出 libs/util 是从外部同步来的,直接在目录里自由改动,改完也没人记得回推。Submodule 目录至少有 .gitmodules 和独立的仓库状态可查,Subtree 的「出身」藏在提交历史里,肉眼不可见。项目里用了 Subtree,必须在 README 里写清楚:哪些目录是从哪同步来的、同步命令是什么。
三个方案各干各的活,别老想着让一个干掉另一个。下次再纠结,先问自己一句:这代码是你自己团队在写,还是要跟外部仓库打交道?前者收进单仓;后者再分------只想用、基本不改的用 submodule,想抄进来自己魔改又不断奶的用 subtree。你手里是哪一种?
参考来源
- Atlassian Git 教程《Git subtree:Git 子模块的替代方案》(subtree add/pull/push 基本用法):www.atlassian.com/zh/git/tuto...
- Git 官方源码 contrib/subtree/git-subtree.sh(脚本作者 Avery Pennarun,版权 2009;subtree 随 Git 官方源码树一起维护):github.com/git/git/blo...
- Pro Git《Git 工具------高级合并》(子树合并小节讲的是底层的 subtree merge 策略,
git subtree命令内部用的就是它,两者不是一回事):git-scm.com/book/zh/v2/... - Git 官方文档《Git 工具------子模块》(对照阅读 submodule 的官方标准用法):git-scm.com/book/zh/v2/...
- Stack Overflow「Differences between git submodule and subtree」(社区对两者差异的高票总结):stackoverflow.com/questions/3...