【笔记】Git Submodule 与 Subtree 的存储模型
1. 一句话心智模型
Submodule 和 Subtree 都能把另一个项目放进当前仓库,但父仓库保存的东西完全不同:
- Submodule 保存一个指向外部仓库 commit 的 gitlink,子项目内容仍由另一个仓库拥有。
- Subtree 把子项目文件和历史导入父仓库,目录从此就是父仓库中的普通 tree 和 blob。
flowchart LR
subgraph SM[Submodule]
A[父仓库 tree] -->|gitlink 160000| B[子仓库 commit ID]
B -.需要另一个 object store.-> C[子项目 tree / blob]
end
subgraph ST[Subtree]
D[父仓库 tree] --> E[普通子目录 tree]
E --> F[普通 blob]
end
所以选型的核心不是“哪个命令方便”,而是子项目的所有权边界应该留在仓库外,还是把内容复制进当前仓库。
2. 先从 Git tree 对象理解两者
Git 的目录快照由 tree 对象组成。tree entry 记录名称、mode 和对象 ID:
1
2
3
100644 blob <object-id> README.md
040000 tree <object-id> src
160000 commit <object-id> vendor/lib
常见 mode 的含义是:
| mode | 类型 | 含义 |
|---|---|---|
100644 | blob | 普通文件 |
100755 | blob | 可执行文件 |
120000 | blob | 符号链接的目标文本 |
040000 | tree | 子目录 |
160000 | commit | gitlink,即 Submodule 指针 |
Submodule 的特殊性就在最后一行:tree entry 指向的不是当前仓库中的 blob 或 tree,而是子仓库的某个 commit ID。
Subtree 没有特殊 mode。导入后的目录仍是 040000 tree,里面仍是普通 blob 和子 tree。
3. Submodule:父仓库保存外键
3.1 父仓库真正提交了什么
加入名为 notes 的 Submodule 后,父仓库通常提交两个信息:
.gitmodules:记录模块名称、路径和默认 URL。notes路径上的 gitlink:记录子仓库的确切 commit ID。
.gitmodules 是普通、受版本控制的文本文件:
1
2
3
[submodule "notes"]
path = notes
url = https://example.com/notes.git
gitlink 可以通过下面的命令看到:
1
2
git ls-tree HEAD notes
# 160000 commit <child-commit-id> notes
父仓库保存的是 commit ID,不是分支名。即使 .gitmodules 里配置了用于更新的分支,父仓库的一次确定快照最终仍指向具体 commit。
3.2 四个位置分别保存什么
初始化后的典型布局是:
1
2
3
4
5
6
7
8
9
10
11
12
superproject/
├── .gitmodules 项目级声明,进入父仓库历史
├── .git/
│ ├── config 本地 Submodule 配置
│ ├── index notes 的 mode 160000 条目
│ └── modules/notes/ 子仓库的 Git directory
│ ├── objects/
│ ├── refs/
│ └── HEAD
└── notes/ 子模块工作树
├── .git gitfile,不是真实目录
└── ...
notes/.git 的内容类似:
1
gitdir: ../.git/modules/notes
实际相对路径由目录层级决定,不能依赖固定的 ../ 数量。
这里有两个容易误解的点:
.git/modules/notes位于父仓库.git下面,不代表两者共享 object store。它仍是子仓库自己的 Git directory,拥有独立的 objects、refs 和 HEAD。- 现代 Git 把子模块 Git directory 与工作树分离,主要是为了让父仓库可以移除、切换或重建子模块工作树,而不顺手删掉子仓库对象。
3.3 clone、init、update 各自做什么
克隆父仓库后,Git 已经拿到 .gitmodules 和 gitlink,但默认不一定取得子仓库对象和工作树内容。
sequenceDiagram
participant U as User
participant P as Parent Repo
participant C as Child Remote
U->>P: git clone parent
Note over P: 得到 .gitmodules + gitlink
U->>P: git submodule init
Note over P: 将选定模块配置写入本地 .git/config
U->>P: git submodule update
P->>C: clone/fetch child objects
Note over P: checkout gitlink 指定 commit
几个命令的边界是:
git submodule init:根据.gitmodules初始化本地配置,不下载内容。git submodule update:取得缺少的子仓库对象,并把工作树检出到父仓库记录的 commit。git submodule update --init:把两步合并,是克隆后最常见的做法。git clone --recurse-submodules:在 clone 阶段递归完成初始化和更新。
.gitmodules 和 .git/config 分层的价值是:项目提交共享的路径和默认 URL,本地配置则可以选择激活范围、覆盖 URL 或保存本机更新策略。
这也形成一道信任边界:仓库可以声明子模块来源,但网络访问和本地配置发生在用户显式递归 clone、init/update 或等价操作时。不能因为 URL 出现在 .gitmodules 就把它视为可信地址。
3.4 更新 Submodule 是两次提交
在子模块中修改代码时,提交属于子仓库:
1
2
3
4
5
6
child repo: C1 -- C2
^
└── 子项目先提交并推送
parent repo: P1 -- P2
└── gitlink 从 C1 改为 C2
完整协作包含两个独立动作:
- 在子仓库提交并确保其他人能从其 remote 取得新 commit。
- 在父仓库提交 gitlink 的变化。
只做第一步,父仓库仍固定在旧 commit;只做第二步但没有推送子仓库 commit,其他人会得到“父仓库引用了无法获取的对象”。
4. Subtree:父仓库接管内容
4.1 导入后只是普通目录
执行:
1
git subtree add --prefix=notes <repository> <ref>
Git 会取得目标历史,并把目标 tree 放到 notes/ 前缀下。最终快照类似:
1
2
3
4
5
040000 tree <tree-id> notes
# 继续查看 notes tree
100644 blob <blob-id> index.md
040000 tree <tree-id> docs
这里没有 gitlink、.gitmodules 或嵌套 .git。普通 clone 已经包含 notes/ 的全部文件,构建工具和 IDE 也无需理解子仓库机制。
4.2 “仍能同步远端”来自历史变换,不是对象边界
git subtree 能够 pull 和 push,并不说明 Git 对象模型记住了“这个目录是特殊仓库”。这些能力由命令根据 prefix 和提交历史计算出来:
subtree pull:取得远端提交,再把它合并到指定前缀。subtree split:扫描父仓库历史,只投影指定前缀的变化,构造一条可作为独立仓库使用的合成历史。subtree push:先 split,再把生成的历史推到目标 remote/ref。
可以把 split 理解成投影:
1
2
3
4
5
6
7
8
父仓库提交:
P1 修改 app/ 和 notes/
P2 只修改 app/
P3 只修改 notes/
对 notes/ 做 split:
S1 只包含 P1 中 notes/ 的变化
S2 只包含 P3 中 notes/ 的变化
生成的 S1、S2 是新的 commit,tree 根对应原来的 notes/ 内容。由于 commit 内容和父节点发生变化,它们的 ID 不会等于父仓库的 P1、P3。
4.3 --squash 改变的是历史粒度
不使用 --squash 时,导入可以保留并连接子项目历史;使用 --squash 时,一次导入或更新会把上游一段变化压成一个合并结果。
两种方式的当前文件快照可以相同,区别在历史:
- 保留历史:更容易追踪上游提交,但父仓库 DAG 更大、更复杂。
- squash:父仓库历史更紧凑,但无法直接逐个浏览上游提交,后续同步仍依赖 subtree 命令维护的提交关系。
无论是否 squash,导入后的文件都是父仓库 object store 中的普通对象。
5. 对象归属怎样推导日常体验
| 问题 | Submodule | Subtree |
|---|---|---|
| 父仓库是否直接保存子项目文件 | 否,只保存 gitlink | 是,保存普通 tree/blob |
| 普通 clone 是否立即得到内容 | 默认否,需要递归 clone 或 update | 是 |
| 是否存在独立 object store | 是 | 否,导入对象进入父仓库 |
| 子目录中能否独立执行 Git | 能,它有自己的 Git directory | 不能,它只是父仓库目录 |
| 父仓库提交能否原子包含子项目修改 | 只能原子记录新指针 | 能,文件修改就在同一提交中 |
| 上游历史怎样保留 | 天然留在子仓库 | 导入或 squash 到父仓库 |
| 工具是否需要特殊理解 | 需要识别 gitlink 和递归操作 | 普通 tree 遍历即可 |
| 权限边界 | 可以分别控制两个仓库 | 得到父仓库通常就得到导入内容 |
这些体验不是命令偶然造成的,而是从“外键”与“内容复制”两种对象模型直接推导出来的。
6. 怎样选择
6.1 适合 Submodule 的情况
- 子项目有独立发布、权限和审计边界。
- 父仓库必须精确固定一个外部 commit,但不应接管其历史。
- 多个仓库共享同一子项目,不希望各自复制完整内容。
- 团队可以接受递归 clone、双仓库提交和指针更新流程。
代价是所有参与者和自动化工具都必须理解 Submodule。CI 漏掉递归初始化、开发者忘记提交 gitlink、父仓库引用尚未推送的子 commit,都是常见失败模式。
6.2 适合 Subtree 的情况
- 父仓库需要开箱即用,普通 clone 后就能构建。
- 子项目内容要和父项目一起原子修改、评审和回滚。
- 下游贡献者不需要理解额外仓库边界。
- 与上游的同步频率不高,可以集中由少数维护者执行。
代价是复制历史和权限边界变弱。双向同步越频繁、父子两边同时修改越多,subtree split/pull 的认知和冲突成本越高。
6.3 两个反向检查问题
选型前可以先问:
- 如果上游仓库明天消失,父仓库是否必须仍能独立构建?如果必须,Subtree 更自然。
- 如果父仓库用户没有子项目权限,是否仍应看到子项目内容?如果不应该,Submodule 才能保留独立访问边界。
7. 常见误区
7.1 “Submodule 在父仓库 .git 下,所以共享对象库”
错误。.git/modules/notes 是子仓库自己的 Git directory,只是物理位置由父仓库托管。父仓库 .git/objects 和子仓库 .git/modules/notes/objects 仍然独立。
7.2 “gitlink 指向子模块分支”
错误。gitlink 记录具体 commit ID。分支配置只影响更新策略,不改变父仓库快照的确定性。
7.3 “Subtree 是嵌套仓库”
错误。导入后的目录没有独立 .git,只是父仓库的普通内容。它能与上游同步,是因为 git subtree 可以投影和重构历史。
7.4 “Subtree 使用 squash 就不保存子项目对象”
错误。squash 只压缩提交历史。当前快照所需的 tree 和 blob 仍必须存在于父仓库 object store。
7.5 “init 会下载 Submodule”
错误。git submodule init 主要初始化本地配置;实际 clone/fetch 和 checkout 发生在 git submodule update。
8. 复习索引
- 本质差异:Submodule 保存外部 commit 指针;Subtree 保存导入后的实际内容。
- 对象锚点:mode
160000是 gitlink;普通目录是 mode040000的 tree。 - 四个位置:
.gitmodules、父仓库 index、.git/modules/<name>、子模块工作树。 - 初始化链路:clone 获得声明和指针,init 初始化本地配置,update 取得对象并检出目标 commit。
- Submodule 更新:先提交和推送子仓库,再提交父仓库 gitlink。
- Subtree 同步:pull 把上游合入 prefix;split 把 prefix 历史投影成独立历史;push 基于 split 结果。
- squash 边界:只改变历史粒度,不改变导入文件属于父仓库的事实。
- 选型问题:需要独立所有权边界,还是需要父仓库开箱即用和原子修改?