文章

【笔记】Git 对象边界:Clone、Submodule 与 Subtree

【笔记】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 数据。因此 CountingCompressing 可以远小于 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类型含义
100644blob普通文件
100755blob可执行文件
120000blob符号链接的目标文本
040000tree子目录
160000commitgitlink,即 Submodule 指针

Submodule 的特殊性就在最后一行:tree entry 指向的不是当前仓库中的 blob 或 tree,而是子仓库的某个 commit ID。

Subtree 没有特殊 mode。导入后的目录仍是 040000 tree,里面仍是普通 blob 和子 tree。

5. Submodule:父仓库保存外键

5.1 父仓库真正提交了什么

加入名为 notes 的 Submodule 后,父仓库通常提交两个信息:

  1. .gitmodules:记录模块名称、路径和默认 URL。
  2. 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

完整协作包含两个独立动作:

  1. 在子仓库提交并确保其他人能从其 remote 取得新 commit。
  2. 在父仓库提交 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/ 的变化

生成的 S1S2 是新的 commit,tree 根对应原来的 notes/ 内容。由于 commit 内容和父节点发生变化,它们的 ID 不会等于父仓库的 P1P3

6.3 --squash 改变的是历史粒度

不使用 --squash 时,导入可以保留并连接子项目历史;使用 --squash 时,一次导入或更新会把上游一段变化压成一个合并结果。

两种方式的当前文件快照可以相同,区别在历史:

  • 保留历史:更容易追踪上游提交,但父仓库 DAG 更大、更复杂。
  • squash:父仓库历史更紧凑,但无法直接逐个浏览上游提交,后续同步仍依赖 subtree 命令维护的提交关系。

无论是否 squash,导入后的文件都是父仓库 object store 中的普通对象。

7. 对象归属怎样推导日常体验

先把三个机制放在同一张表中:

机制当前仓库最终保存什么是否需要另一个 object store主要解决的问题
Clone所请求对象的本地副本创建可独立使用的新仓库
Submodule外部 commit ID 和来源配置精确引用独立仓库版本
Subtree导入后的普通 tree、blob 和历史把外部内容纳入当前仓库管理
问题SubmoduleSubtree
父仓库是否直接保存子项目文件否,只保存 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 两个反向检查问题

选型前可以先问:

  1. 如果上游仓库明天消失,父仓库是否必须仍能独立构建?如果必须,Subtree 更自然。
  2. 如果父仓库用户没有子项目权限,是否仍应看到子项目内容?如果不应该,Submodule 才能保留独立访问边界。

9. 常见误区

9.1 “Submodule 在父仓库 .git 下,所以共享对象库”

错误。.git/modules/notes 是子仓库自己的 Git directory,只是物理位置由父仓库托管。父仓库 .git/objects 和子仓库 .git/modules/notes/objects 仍然独立。

错误。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 行里的 reusedpack-reused 才能进一步分析。

10. 复习索引

  • 统一模型:Clone 复制对象,Submodule 引用外部对象,Subtree 把外部对象并入当前仓库。
  • Clone 统计:Enumerating、Counting、Compressing 和 Receiving 的执行方及统计口径不同。
  • 复用边界:缺少 Total ... reused ... pack-reused ... 时,不能把较小的 Counting 数解释成新增或松散对象数。
  • 本质差异:Submodule 保存外部 commit 指针;Subtree 保存导入后的实际内容。
  • 对象锚点:mode 160000 是 gitlink;普通目录是 mode 040000 的 tree。
  • 四个位置.gitmodules、父仓库 index、.git/modules/<name>、子模块工作树。
  • 初始化链路:clone 获得声明和指针,init 初始化本地配置,update 取得对象并检出目标 commit。
  • Submodule 更新:先提交和推送子仓库,再提交父仓库 gitlink。
  • Subtree 同步:pull 把上游合入 prefix;split 把 prefix 历史投影成独立历史;push 基于 split 结果。
  • squash 边界:只改变历史粒度,不改变导入文件属于父仓库的事实。
  • 选型问题:需要独立所有权边界,还是需要父仓库开箱即用和原子修改?

11. 参考资料

本文由作者按照 CC BY 4.0 进行授权