Git 的“多仓库集成”方案
一、Git Submodule(子模块)
核心思想:
主仓库只记录一个指针(某个子仓库的某次提交 SHA),不直接包含子仓库的文件。子模块的代码库仍然独立存在,可以单独开发、打 tag、管理 issue。
1. 基本原理
- 主仓库根目录下有一个
.gitmodules文件,保存子模块的本地路径和远程 URL。 - 主仓库将子模块对应的目录记录为一个特殊对象(mode 160000),指向子模块仓库的某个 commit。
- 当你克隆主仓库时,子模块目录是空的,需要手动执行
git submodule update --init才能真正拉取对应版本的代码。
这种感觉就很像“软链接”——主仓库里只占一个指针,真正的体积在链接的目标里(只不过这里的目标是另一个 Git 仓库的特定快照)。
2. 常用操作流程
添加子模块
cd main-project
git submodule add https://github.com/user/libfoo.git libs/foo
git commit -m "Add libfoo as a submodule"
此时会生成/修改 .gitmodules,并记录 libs/foo 指向的 commit。
克隆带有子模块的项目
git clone --recurse-submodules https://github.com/user/main-project.git
如果已经普通克隆了,可以后续执行:
git submodule init
git submodule update
更新子模块到远程最新提交
cd libs/foo
git fetch
git checkout main && git pull # 或者直接 git pull
cd ../..
git add libs/foo
git commit -m "Update submodule to latest"
子模块默认处于 detached HEAD 状态(指向一个具体的 commit),需要先切换到分支再做更新。
修改子模块代码并提交 子模块自身就是一个完整的 Git 仓库,你可以像独立项目一样在里面修改、提交、推送:
cd libs/foo
# 改代码...
git add .
git commit -m "Fix bug in libfoo"
git push origin main
然后回到主仓库,把子模块新的 commit 指针记录下来:
cd ../..
git add libs/foo
git commit -m "Update libfoo to fix a bug"
删除子模块
git submodule deinit -f libs/foo
git rm -f libs/foo
rm -rf .git/modules/libs/foo # 清理子模块的内部元数据(可选)
如果 .gitmodules 里没有自动移除对应条目,可以手动编辑后提交。
3. 优缺点
优点: - 子模块历史完全独立,互不污染。 - 主仓库体积很小,仅保存一个 commit 引用。 - 适合引用第三方库,且能精确锁定某个特定版本(如 release tag 对应的 commit)。
缺点:
- 操作门槛高:新手经常忘记 git submodule update,导致编译报错。
- 分支管理复杂:子模块默认游离 HEAD,修改时容易丢失。
- 切换主仓库分支后,子模块版本不会自动跟随,需要手动执行 git submodule update。
- 子模块的修改需要分别在子模块和主仓库中提交/推送,容易遗漏。
二、Git Subtree(子树)
核心思想:
将另一个仓库的完整历史合并到主仓库的一个子目录里,就像它本来就是主仓库的一部分。没有额外的指针或元数据,主仓库直接拥有所有文件。
1. 基本原理
- 使用
git subtree add时,Git 会读取远程子仓库的某条分支的历史,然后将其整个提交历史压缩或不压缩地合并到主仓库的指定子目录下。 - 最终效果:子仓库的所有文件都被“复制”到主仓库中,并且主仓库的 git log 里可以看到来自子仓库的提交记录(可以完整保留子仓库的 commit 信息,也可以用 squash 压成一个提交)。
- 之后你可以像修改自己仓库的文件一样修改这些文件,所有的改动都在主仓库中提交。
- 如果需要把主仓库中对这个子目录的修改推送回子仓库的原始远程,可以使用
git subtree push;从子仓库拉取新的更新,可以用git subtree pull。
它不再是“指针”或“软链接”,而是直接把代码内嵌了,但通过 subtree 命令可以方便地双向同步。
2. 常用操作流程
添加一个 subtree(将子仓库的 master 分支合并到 libs/bar 目录)
git subtree add --prefix=libs/bar https://github.com/user/libbar.git master --squash
--squash表示把子仓库的全部历史压缩成一个提交,避免主仓库历史太乱。如果要完整保留子仓库的每一次提交,可以去掉这个选项。
像普通代码一样修改、提交
# 编辑 libs/bar/xxx
git add libs/bar/
git commit -m "Update bar library"
从子仓库拉取更新
git subtree pull --prefix=libs/bar https://github.com/user/libbar.git master --squash
这会把子仓库 master 分支最新的改动合并到 libs/bar 目录中。如果主仓库也有对该目录的改动,可能会产生合并冲突,像正常的 merge 一样解决即可。
把本地对子目录的修改推回子仓库
git subtree push --prefix=libs/bar https://github.com/user/libbar.git master
这会将主仓库中影响 libs/bar 的所有提交提取出来,并推送回子仓库的 master 分支。但通常建议在推送前先 git subtree split 检查要推送的内容。
从现有目录拆分成独立仓库 如果你想把主仓库的某个子目录独立出去,成为一个新仓库:
git subtree split --prefix=libs/bar -b bar-only-branch
然后可以把 bar-only-branch 这个分支推到一个新的远程仓库中。
3. 优缺点
优点: - 操作简单,克隆主仓库后什么额外步骤都不需要,所有代码直接可用。 - 不需要学习子模块的特殊命令,大部分流程就像在同一个仓库里工作。 - 适合团队内部共享组件,所有人都能直接看到组件的代码和修改。
缺点: - 主仓库体积会显著变大(包含了子仓库的全部历史)。 - 从子仓库同步更新或推送回去时需要执行 subtree 命令,有可能会产生较难处理的冲突。 - 子仓库原始的 tag、分支结构会被“拍平”到主仓库历史中,如果不用 squash 会污染主仓库的历史图。 - 对大项目而言,subtree 的 split/push 操作可能很慢。
三、Submodule 与 Subtree 对比总结
| 特性 | git submodule | git subtree |
|---|---|---|
| 代码存储位置 | 独立仓库,主仓库只存指针 | 完整包含在主仓库的子目录中 |
| 克隆时 | 需要额外步骤拉取子模块内容 | 直接克隆,不需要额外操作 |
| 版本锁定 | 锁定到某个 commit | 合入了特定分支的历史,后续可同步 |
| 修改后回传 | 需要在子模块内独立 push | 使用 git subtree push 提取提交并推送 |
| 历史隔离性 | 完全隔离,互不干扰 | 子仓库历史被混合进主仓库 |
| 仓库体积 | 主仓库体积小 | 主仓库体积大 |
| 易用性 | 新手易出错 | 相对直观,但 push/pull 有学习成本 |
| 适用场景 | 第三方依赖,需要严格版本锁定 | 共享内部组件,且希望克隆即用 |
四、如何选择?
更接近“软连接”感觉的是 Submodule,因为它只存引用,符合你最初的想法——主仓库干净,需要时再“连接”到子仓库的内容。
- 如果你的项目引用的是外部开源库,或者需要精确控制依赖版本、且不经常修改这些库的代码,选 submodule。
- 如果这些库是你们团队内部共享的组件,经常需要一起修改、希望克隆后一次到位,选 subtree 会更省心。
- 如果你希望最像软链接,但又需要严格的版本控制和分布式协作,那 submodule 是你最好的选择。记得给团队写好
git clone --recurse-submodules的文档,并考虑在 CI 中自动化子模块更新。