Git 子模块的核心:不是复制代码,而是固定引用一个仓库
我第一次真正注意到 Git 子模块,是在看当前这个博客项目的时候。
项目根目录里有一个 .gitmodules 文件。刚看到它时,很容易以为这只是 Git 的某个额外配置。但继续看目录结构就会发现,它其实说明了一件更重要的事:这个项目里有一部分代码,并不属于主仓库本身,而是来自另一个 Git 仓库。
以博客项目为例,主仓库通常负责这些内容:
- 文章内容
- 站点配置
- 部署脚本
- 自己写的少量定制代码
但主题、模板或者某个公共组件,可能来自另一个开源仓库。这个时候就会出现一个问题:主项目需要使用那份代码,但又不想真的把那份代码复制进来。
Git 子模块就是为了解决这个问题。
先看一个具体场景
假设我有一个博客项目,仓库叫 myblog。
它的目录大概是这样:
其中 content/ 和 config.toml 是我自己的内容,应该直接提交到 myblog。
但 themes/blog-theme/ 不是我从零写的,它来自另一个仓库:
| |
这里有三种处理方式。
第一种是直接复制主题代码到 themes/blog-theme/。这看起来最简单,但后面会很难维护。因为复制之后,我就很难知道这些代码最初来自哪里,也很难继续合并原作者后续更新。
第二种是每次构建时临时下载主题。这对一些项目可以,但对于博客主题这种需要本地改配置、看源码、偶尔还要定制的东西,不一定方便。
第三种就是 Git 子模块:主项目保留 themes/blog-theme/ 这个目录,但这个目录本身由另一个 Git 仓库提供。
这就是子模块最核心的作用:让一个 Git 仓库把另一个 Git 仓库当作子目录来使用,同时又保留两个仓库各自的边界。
.gitmodules 到底记录了什么
当项目使用子模块时,主仓库里会多一个 .gitmodules 文件。
它大概长这样:
这个文件只说明两件事:
- 子模块放在哪个目录
- 子模块应该从哪个远程仓库拉取
也就是说,.gitmodules 记录的是“地址映射关系”。
但这还不是全部。主仓库还会额外记录这个子模块当前指向哪个提交。
这点很关键。
主仓库并不会把 themes/blog-theme/ 里的每个文件都当成自己的文件来跟踪。它只会记录一句话:
| |
你可以把它理解成一个固定版本号。
比如:
| |
所以 Git 子模块不是“把别人的代码复制进来”,而是“在主仓库里固定引用另一个仓库的某一次提交”。
这就是它的核心。
为什么这件事有价值
还是用博客主题举例。
如果主题仓库今天更新了一个版本,但这个版本有 bug,我不希望我的博客项目自动跟着坏掉。
子模块不会自动追最新版本。它会停在主仓库记录的那个提交上。
这带来了一个很重要的好处:可复现。
别人 clone 我的博客项目时,只要把子模块也初始化下来,他拿到的主题代码就应该和我提交时使用的是同一个版本。
这比“文档里写一句请安装某某主题最新版”可靠得多。因为最新版一直在变,但提交号不会变。
为什么刚 clone 下来时子模块可能是空的
很多人第一次接触子模块时,会遇到一个现象:
| |
clone 完之后,子模块目录存在,但里面可能是空的,或者没有真正的内容。
这是正常的。
因为主仓库只先拿到了 .gitmodules 和子模块指向的提交号,还没有真正把子模块仓库拉下来。
这时需要执行:
| |
如果从一开始就知道项目有子模块,也可以直接:
| |
这条命令的意思是:clone 主仓库时,把它引用的子模块也一起拉下来。
日常更新子模块时,真正更新的是什么
假设主题仓库修了一个 bug,我希望博客项目使用新版本。
直觉上会觉得:进入 themes/blog-theme/,执行 git pull 就完了。
但这只完成了一半。
这一步只是让子模块目录里的代码更新到了新提交。
接下来必须回到主仓库,再提交一次:
为什么还要提交主仓库?
因为主仓库记录的是“子模块指向哪个提交”。当子模块从 a1b2c3d 更新到 e4f5g6h 时,主仓库也要把这个指针变化记录下来。
所以更新子模块,本质上不是把一堆主题文件提交到主仓库,而是修改主仓库里的这条引用:
| |
这也是很多人对 Git 子模块困惑的地方:你看到的是一个目录,但 Git 看到的是另一个仓库的提交号。
如果我想魔改这个主题,应该怎么办
这才是子模块在真实项目里最容易遇到的问题。
一开始我只是引用别人的主题。后来我想改样式、改布局、加自己的逻辑。这时应该怎么处理?
这里有两个方向。
方案一:fork 原仓库,然后让子模块指向自己的 fork
这是我更推荐的方式,尤其适合“我想改,但还希望保留上游更新能力”的情况。
原因很简单:原来的主题仓库不是我的,我没有权限直接往里面推代码。即使我在本地子模块目录里改了代码,这些改动也需要一个地方保存。
最合理的地方就是自己的 fork。
流程大概是这样。
先在 GitHub 或 Gitee 上 fork 原主题仓库。
原来子模块可能指向这里:
fork 之后,把它改成自己的仓库:
然后同步子模块配置:
| |
进入子模块目录,确认远程地址:
如果地址还是旧的,可以改掉:
| |
之后,你对子模块的修改就提交到自己的 fork:
最后回到主仓库,把 .gitmodules 的变化和子模块指针一起提交:
这样处理之后,关系就变得很清楚:
myblog仍然是主仓库blog-theme仍然是独立仓库- 只是主仓库引用的主题仓库,从原作者版本变成了我的 fork
这个方案的好处是,后续还可以同步上游。
比如在自己的 fork 里保留原作者仓库地址:
以后原作者修了 bug,我可以选择性合并:
| |
所以方案一的本质是:我接管了这个依赖,但没有抹掉它和上游的关系。
这适合大多数“基于开源项目做定制”的情况。
方案二:把子模块彻底并入主仓库
另一个方向是:不要子模块了,直接把它变成主仓库里的普通目录。
这个方案适合另一种情况:我已经不关心上游更新了,或者这份代码已经被我改得和原项目差异很大,再保留子模块只会增加理解成本。
比如一个主题原本来自开源仓库,但我后来把目录结构、样式、组件、构建方式都改了一遍。这个时候它已经不太像原来的主题了。继续把它当成外部依赖,意义就不大。
这时可以考虑把它并入主仓库。
但要注意,这不是简单地删除 .gitmodules 就结束了。
子模块在 Git 里有自己的记录。要把它变成普通目录,需要做几件事:
- 确认子模块代码已经完整拉下来
- 从主仓库索引里移除子模块引用
- 删除
.gitmodules里的对应配置 - 去掉子模块目录里的 Git 元信息
- 重新把这些文件作为普通文件加入主仓库
以 themes/blog-theme 为例,先确认代码完整:
| |
然后从主仓库索引里移除它的子模块身份,但尽量保留工作区文件:
| |
接着删除 .gitmodules 里对应的这一段:
然后检查 themes/blog-theme/ 里是否还有 .git 文件或目录。如果有,就删除它。否则 Git 仍然会把它当成一个嵌套仓库,而不是普通目录。
最后重新加入主仓库:
完成之后,themes/blog-theme/ 就和 content/ 一样,成为主仓库直接管理的普通目录。
这个方案的好处是简单:以后 clone 项目不用再初始化子模块,改主题也不用在两个仓库之间来回切。
但代价也很明确:你基本放弃了和原主题仓库的自然同步能力。以后原作者更新了代码,你不能再像子模块那样直接拉取某个上游提交,只能手工对比和合并。
所以方案二的本质是:我不再把它当外部依赖,而是正式把它收进当前项目。
两个方案怎么选
我会按这个标准判断。
如果这份代码还像一个独立项目,只是我需要在它基础上做一些定制,选方案一。
比如主题还是主题,组件库还是组件库,只是我要改几个样式、加几个功能。这时 fork 一份,然后继续作为子模块引用,最稳。
如果这份代码已经变成当前项目的一部分,不再准备跟随上游,选方案二。
比如主题已经被改得面目全非,继续保留上游关系反而没有价值。那就把它并入主仓库,降低维护复杂度。
如果暂时不确定,优先选方案一。
因为 fork + 子模块保留了退路。后面如果真的不需要上游了,再并入主仓库也不迟。反过来,如果一开始就并入主仓库,后面再想恢复清晰的上游同步关系,会麻烦很多。
回到当前项目
所以,当前项目里出现 .gitmodules,我现在会这样理解:
它不是一个可有可无的配置文件,而是在告诉我:
这就是 Git 子模块真正要表达的东西。
它解决的不是“怎么把目录嵌套起来”的问题,而是“主项目如何稳定引用另一个独立项目”的问题。
对于博客项目来说,这个独立项目常常是主题。
对于其他工程来说,它可能是 SDK、公共组件、算法库、插件、文档模板,或者任何需要独立维护、但又必须放进主项目一起使用的代码。
总结
Git 子模块的核心可以压缩成一句话:
主仓库不保存子模块的全部代码历史,只保存子模块的来源、路径,以及当前指向的提交。
理解了这句话,很多现象就都说得通了:
- 为什么 clone 后还要初始化子模块
- 为什么更新子模块后主仓库也有变化
- 为什么直接在子模块里改代码不等于主仓库已经保存了这些改动
- 为什么 fork 后再改,是更适合二次开发的方式
- 为什么彻底并入主仓库,会失去和上游自然同步的能力
所以看到 .gitmodules 时,不要只把它当成一个 Git 配置文件。
它其实是在说明这个项目的依赖边界。