【笔记】Git 对象边界:Clone、Submodule 与 Subtree
1. 一句话心智模型
Clone、Submodule 和 Subtree 都在处理“另一个仓库里的对象怎样进入当前工作环境”,但它们改变对象边界的方式不同:
- Clone 通过协商和 pack 传输,把目标对象复制进一个新的本地 object store。
- Submodule 只在父仓库保存外部 commit 的 gitlink,子项目对象仍属于独立 object store。
- Subtree 把子项目的 tree、blob 和相关历史导入父仓库,父仓库从此直接拥有这些对象。
flowchart LR
R[远端对象库] -->|Clone: pack 传输| L[新的本地对象库]
P[父仓库 tree] -->|Submodule: gitlink 160000| C[子仓库 commit]
S[子仓库 tree / blob] -->|Subtree: 历史变换与导入| P2[父仓库普通 tree / blob]
因此,理解这三个机制的共同入口不是命令用法,而是两个问题:对象最终归哪个 object store 所有,仓库快照保存的是对象本身还是跨仓库指针。
2. Clone:把对象复制到新的 object store
2.1 Clone 不等于复制远端 .git 目录
普通网络 Clone 会先交换引用和能力,再由服务端根据客户端需要构造传输 pack。客户端接收 pack、建立索引和引用,最后检出工作树。
对于没有任何本地对象的完整 Clone,客户端通常需要远端目标引用可达的全部 commit、tree、blob 和 tag;浅克隆、部分克隆、单分支克隆会改变这个集合。已有对象的 fetch 则还要结合客户端声明的 have,排除客户端已经拥有的对象。
sequenceDiagram
participant C as Client
participant S as Server
C->>S: 请求 refs,并声明能力与已有对象
S->>S: 计算需要发送的对象
S->>S: 复用或重新编码对象,生成传输 pack
S-->>C: sideband 进度 + pack 字节流
C->>C: index-pack、校验连通性、更新 refs
C->>C: checkout 工作树
Pack 既是磁盘归档格式,也是仓库之间传输对象的格式。对象可以完整压缩,也可以保存为相对另一个对象的 delta;传输使用 thin pack 时,还可暂时省略客户端已经拥有的 delta base,客户端收到后再补成自包含 pack。
2.2 四类进度不是同一统计口径
典型输出如下:
1
2
3
4
remote: Enumerating objects: 261198, done.
remote: Counting objects: 100% (75/75), done.
remote: Compressing objects: 100% (37/37), done.
Receiving objects: 100% (261198/261198), done.
不能把四个数字理解成同一个集合依次经过四道流水线:
| 输出 | 执行方 | 准确含义 |
|---|---|---|
Enumerating objects | 服务端 | 遍历并确定这次传输涉及的对象范围 |
Counting objects | 服务端 | pack-objects 为需要按常规路径处理的对象建立计数,供后续进度显示 |
Compressing objects | 服务端 | 对其中需要重新选择或生成压缩表示的对象进行处理;能复用已有表示的对象不必都重新压缩 |
Receiving objects | 客户端 | 接收传输 pack 中的对象总数,不只包含前面重新处理的部分 |
Git 可以直接复用已有 pack 中的对象表示和 delta,避免解压后重新计算;启用 reachability bitmap 等优化时,还可能整段复用已有 pack 数据。因此 Counting 和 Compressing 可以远小于 Receiving。
但只看到上面四行,不能进一步断言 75 个对象就是“新增对象”“松散对象”或“临时生成的小 pack”。完整输出通常还会有:
1
remote: Total 261198 (delta ...), reused ... (delta ...), pack-reused ...
这里才会明确区分总对象数、对象级复用和 pack 级复用。如果缺少这一行,只能确认统计口径不同,不能可靠还原 75 个对象的来源。
2.3 复用的是编码结果,不是跳过对象交付
“服务端已有 pack”容易引出一个错误类比:把传输过程想成原样发送一个旧大 pack,再附加一个新小 pack。Git 的真实保证是客户端最终收到所需对象;服务端可以从已有 pack 复用对象字节、delta 或适合整段复用的数据,也可以为不适合复用的部分重新选择 delta 和压缩表示。最终是一个传输 pack 字节流,不应从进度数字反推为两个独立 pack 文件。
这也解释了三个容易混淆的概念:
- 对象集合回答“客户端最终需要哪些 commit、tree、blob”。
- pack 表示回答“这些对象怎样压缩、怎样建立 delta”。
- 进度计数回答“当前实现路径正在枚举、重新处理或接收多少对象”。
同一个对象集合可以有不同的合法 pack 表示;对象 ID 由对象内容决定,不由它在 pack 中的压缩方式决定。
3. Submodule 与 Subtree:引用外部对象,还是接管内容
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
所以选型的核心不是“哪个命令方便”,而是子项目的所有权边界应该留在仓库外,还是把内容复制进当前仓库。
4. 先从 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。
5. Submodule:父仓库保存外键
5.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。
5.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 与工作树分离,主要是为了让父仓库可以移除、切换或重建子模块工作树,而不顺手删掉子仓库对象。
5.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 就把它视为可信地址。
父仓库 checkout 一个包含 gitlink 的提交时,会更新 index 中的 160000 条目,但普通 checkout 不等于初始化并下载子模块。未初始化时,父仓库只有路径、URL 声明和目标 commit ID,没有可用于恢复子模块文件的对象。
已经初始化的子模块也有自己的工作树状态。要显式对齐父仓库记录的 commit,可以执行:
1
git submodule update --init --recursive
也可以设置 submodule.recurse=true,让 checkout、fetch、pull、reset 等支持该配置的命令默认递归处理子模块。但它不是“所有 Git 命令自动递归”的总开关:初次 Clone 仍需要 --recurse-submodules,部分命令也要求自己的递归参数。
5.4 更新 Submodule 是两次提交
在子模块中修改代码时,提交属于子仓库:
1
2
3
4
5
6
child repo: C1 -- C2
^
└── 子项目先提交并推送
parent repo: P1 -- P2
└── gitlink 从 C1 改为 C2
完整协作包含两个独立动作:
- 在子仓库提交并确保其他人能从其 remote 取得新 commit。
- 在父仓库提交 gitlink 的变化。
只做第一步,父仓库仍固定在旧 commit;只做第二步但没有推送子仓库 commit,其他人会得到“父仓库引用了无法获取的对象”。
6. Subtree:父仓库接管内容
6.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 也无需理解子仓库机制。
6.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。
6.3 --squash 改变的是历史粒度
不使用 --squash 时,导入可以保留并连接子项目历史;使用 --squash 时,一次导入或更新会把上游一段变化压成一个合并结果。
两种方式的当前文件快照可以相同,区别在历史:
- 保留历史:更容易追踪上游提交,但父仓库 DAG 更大、更复杂。
- squash:父仓库历史更紧凑,但无法直接逐个浏览上游提交,后续同步仍依赖 subtree 命令维护的提交关系。
无论是否 squash,导入后的文件都是父仓库 object store 中的普通对象。
7. 对象归属怎样推导日常体验
先把三个机制放在同一张表中:
| 机制 | 当前仓库最终保存什么 | 是否需要另一个 object store | 主要解决的问题 |
|---|---|---|---|
| Clone | 所请求对象的本地副本 | 否 | 创建可独立使用的新仓库 |
| Submodule | 外部 commit ID 和来源配置 | 是 | 精确引用独立仓库版本 |
| Subtree | 导入后的普通 tree、blob 和历史 | 否 | 把外部内容纳入当前仓库管理 |
| 问题 | Submodule | Subtree |
|---|---|---|
| 父仓库是否直接保存子项目文件 | 否,只保存 gitlink | 是,保存普通 tree/blob |
| 普通 clone 是否立即得到内容 | 默认否,需要递归 clone 或 update | 是 |
| 是否存在独立 object store | 是 | 否,导入对象进入父仓库 |
| 子目录中能否独立执行 Git | 能,它有自己的 Git directory | 不能,它只是父仓库目录 |
| 父仓库提交能否原子包含子项目修改 | 只能原子记录新指针 | 能,文件修改就在同一提交中 |
| 上游历史怎样保留 | 天然留在子仓库 | 导入或 squash 到父仓库 |
| 工具是否需要特殊理解 | 需要识别 gitlink 和递归操作 | 普通 tree 遍历即可 |
| 权限边界 | 可以分别控制两个仓库 | 得到父仓库通常就得到导入内容 |
这些体验不是命令偶然造成的,而是从“外键”与“内容复制”两种对象模型直接推导出来的。
8. 怎样选择
8.1 适合 Submodule 的情况
- 子项目有独立发布、权限和审计边界。
- 父仓库必须精确固定一个外部 commit,但不应接管其历史。
- 多个仓库共享同一子项目,不希望各自复制完整内容。
- 团队可以接受递归 clone、双仓库提交和指针更新流程。
代价是所有参与者和自动化工具都必须理解 Submodule。CI 漏掉递归初始化、开发者忘记提交 gitlink、父仓库引用尚未推送的子 commit,都是常见失败模式。
8.2 适合 Subtree 的情况
- 父仓库需要开箱即用,普通 clone 后就能构建。
- 子项目内容要和父项目一起原子修改、评审和回滚。
- 下游贡献者不需要理解额外仓库边界。
- 与上游的同步频率不高,可以集中由少数维护者执行。
代价是复制历史和权限边界变弱。双向同步越频繁、父子两边同时修改越多,subtree split/pull 的认知和冲突成本越高。
8.3 两个反向检查问题
选型前可以先问:
- 如果上游仓库明天消失,父仓库是否必须仍能独立构建?如果必须,Subtree 更自然。
- 如果父仓库用户没有子项目权限,是否仍应看到子项目内容?如果不应该,Submodule 才能保留独立访问边界。
9. 常见误区
9.1 “Submodule 在父仓库 .git 下,所以共享对象库”
错误。.git/modules/notes 是子仓库自己的 Git directory,只是物理位置由父仓库托管。父仓库 .git/objects 和子仓库 .git/modules/notes/objects 仍然独立。
9.2 “gitlink 指向子模块分支”
错误。gitlink 记录具体 commit ID。分支配置只影响更新策略,不改变父仓库快照的确定性。
9.3 “Subtree 是嵌套仓库”
错误。导入后的目录没有独立 .git,只是父仓库的普通内容。它能与上游同步,是因为 git subtree 可以投影和重构历史。
9.4 “Subtree 使用 squash 就不保存子项目对象”
错误。squash 只压缩提交历史。当前快照所需的 tree 和 blob 仍必须存在于父仓库 object store。
9.5 “init 会下载 Submodule”
错误。git submodule init 主要初始化本地配置;实际 clone/fetch 和 checkout 发生在 git submodule update。
9.6 “Counting objects 就是新增或松散对象数”
错误。它是 pack 生成路径中的进度计数,可能排除直接复用的对象表示或 pack 数据。必须结合 Total 行里的 reused 和 pack-reused 才能进一步分析。
10. 复习索引
- 统一模型:Clone 复制对象,Submodule 引用外部对象,Subtree 把外部对象并入当前仓库。
- Clone 统计:Enumerating、Counting、Compressing 和 Receiving 的执行方及统计口径不同。
- 复用边界:缺少
Total ... reused ... pack-reused ...时,不能把较小的 Counting 数解释成新增或松散对象数。 - 本质差异: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 边界:只改变历史粒度,不改变导入文件属于父仓库的事实。
- 选型问题:需要独立所有权边界,还是需要父仓库开箱即用和原子修改?