🍃 Git Subtree 完整指南

Subtree 让外部仓库的代码合并进当前仓库,成为当前仓库的普通文件——git diff、git blame、git log 全部照常工作,clone 下来就有,不用额外初始化。

它的核心设计取舍是:放弃「独立仓库身份」,换取「零配置成本」。这是它和 submodule 唯一的本质区别——submodule 存一个指针,subtree 存真实的代码。

🎯 解决什么问题

需要把另一个仓库的代码放进当前仓库一起管理,但又不想让每个使用者都得先跑初始化命令时:

  • 引入的第三方代码要给外部使用者用(开源项目被 fork、模板被复制),你控制不了对方的 clone 命令
  • 希望代码在仓库里看起来就是自己的,review 时能直接看到 diff
  • CI 不想为「初始化依赖」配额外的 key 和 step

对照组是 submodule 的核心痛点:clone 完不跑 git submodule update --init --recursive,目录就是空的;CI 少一步配置,流水线直接失败。subtree 消灭的正是这个成本。

反过来,subtree 也有明确代价(见坑一节):它不记录来源、同步会复制文件、本地改动和上游更新混在一起分不开。能接受这些代价才用 subtree。


🔄 与 submodule 的机制对比

submodulesubtree
父仓库存什么指针(mode 160000 gitlink → commit SHA)真实文件
代码在哪独立 git 目录 .git/modules/<name>父仓库工作区
clone 后目录是空的,需 update --init --recursive直接可用
git diff只显示一行 SHA 变化真实代码 diff
git blame跳到子仓库正常走
外部依赖有(.gitmodules 记录来源)无
仓库体积小大,每次同步复制一遍文件
版本锁定精确 committag / 分支约定

反向操作(从父仓库把子目录推回独立仓库):

# subtree 特有:把某个目录从历史里切成一个独立分支
git subtree split --prefix=vendor/lib --branch=lib-v2

🛠️ 基本操作

引入

# 把外部仓库合并到 vendor/ 目录,历史压成一个 commit
git subtree add --prefix=vendor/quartz https://github.com/jackyzha0/quartz.git v5 --squash

--squash 决定是把对方整个历史压成一个 commit,还是完整 merge 进来。首尾必须一致——用 --squash 加的,以后 pull 也得带 --squash。

同步上游

git subtree pull --prefix=vendor/quartz https://github.com/jackyzha0/quartz.git v5 --squash

推回上游

git subtree push --prefix=vendor/quartz <your-fork> <branch>

内部会先 split 再 push。


📌 来源与版本标记

subtree 在父仓库里存的是普通文件和 commit,不是指针。由此产生两个 submodule 没有的问题:怎么认出哪些 commit 是子树带来的,以及怎么把子目录切回独立分支。

认出子树 commit

git subtree add / pull 产生的 merge commit,正文里带一行 trailer:

git-subtree-dir: vendor/quartz
git-subtree-split: <commit>
# 列出本仓库所有子树标记
git log --format='%H%n%B' | grep -B2 '^git-subtree-dir:'
 
# 某个子树最近一次同步到了哪个上游 commit
git log --format='%H %s%n%b' -- vendor/quartz | grep -m1 'git-subtree-split'

这行 trailer 就是 git 识别子树边界的依据——坑 5 说冲突解完不能丢 commit message,根因在这。

切出独立分支

submodule 天然有独立分支(它本来就是另一个仓库);subtree 要自己切:

# 把 vendor/quartz 的全部历史抽成一个新分支
git subtree split --prefix=vendor/quartz --branch=quartz-v5
 
# 推成独立仓库
git subtree push --prefix=vendor/quartz <your-fork> quartz-v5

split 会重写整段历史(每个 commit 都要重算 SHA),仓库大时很慢。

版本锁定的代价

submodule 的父仓库 commit 精确锁定子模块的某个 SHA。subtree 没有这层保障——它只在历史里留一个 merge commit,你得自己记住「上次同步到哪个版本」。

所以 subtree 的上游版本要靠约定或 tag 管,不能指望 git 帮你锁。这是它换来「零配置」的代价。


⚠️ 常见坑

1. prefix 目录已存在且有文件

fatal: 'vendor/quartz' already exists in the index

subtree 要求目标目录在索引里是干净的。已有内容必须先处理掉(提交、stash 或 git rm -r --cached)。

2. add 和 pull 的 --squash 不一致

这是最高频的一个:

fatal: Cannot use --squash with a subtree that was not added with --squash

subtree 靠历史里那个特殊的 merge commit 识别子树边界。首尾策略不一致它就认不出来。第一次 add 决定了以后每次 pull 都得用同样的策略,没有转换命令,只能重来。

3. 不记录来源 URL

subtree add 不写任何元数据。不像 submodule 有 .gitmodules 记着 path 和 url,subtree 完事之后没人记得这目录是哪来的、该跟哪个分支。

这是 subtree 最实际的维护成本,尤其目录里被本地改过之后——下次同步的人根本不知道该 pull 哪个远端、哪个分支。

预防:立刻把同步命令写进仓库(Makefile、justfile,或 README 的固定小节),别靠记忆和 shell history:

quartz-sync:
	git subtree pull --prefix=.quartz https://github.com/jackyzha0/quartz.git v5 --squash

4. 仓库体积持续膨胀

每次 pull 都是一次 merge,复制一遍全部文件。哪怕只有一行改动,也会产生一份全新的快照。长期同步的依赖会把 .git 撑大。

判断成本:git count-objects -vH 看 .git 体积 vs 裸 clone 的体积。差值就是 subtree 吃掉的部分。

5. 冲突要手动解决,且不能丢 squash commit message

pull 冲突时,先在 prefix 目录里解完:

git subtree pull --prefix=vendor/lib <url> <ref> --squash
# 解冲突
git add vendor/lib
git commit          # message 里保留 "git-subtree-dir: vendor/lib" 这行

那行注释是 git 后续识别子树边界的依据,删了等于自断后路。

6. 本地改过的文件同步时全被覆盖

对 vendored 目录做了本地修改(打 patch、改配置),下次 pull 就是一场解不完的冲突。subtree 不区分「哪些是我改的、哪些是上游的」。

这是跟 submodule 的关键分野:submodule 里你可以干净地维护自己的 patch,subtree 里改了就混在一起,分不开了。


🔧 常用命令速查

# 引入(--squash 决定后续所有 pull 的策略,改不了)
git subtree add --prefix=<dir> <url> <ref> --squash
 
# 同步上游(策略必须与 add 时一致)
git subtree pull --prefix=<dir> <url> <ref> --squash
git subtree pull --prefix=<dir> <url> <ref> --squash -m "更新说明"
 
# 推回上游(内部自动 split)
git subtree push --prefix=<dir> <remote> <ref>
 
# 切出独立分支
git subtree split --prefix=<dir> --branch=<name>
git subtree split --prefix=<dir> <original-commit> --branch=<name>   # 只切到某个版本
 
# 查看子树信息
git log --format='%H %s%n%b' -- <dir> | grep -m1 'git-subtree-split'   # 上次同步到的版本
git log --format='%H%n%B' | grep -B2 '^git-subtree-dir:'                # 所有子树标记
git log --oneline -- <dir>                                            # 子树相关的 commit
 
# 诊断体积膨胀
git count-objects -vH                                                  # 对比裸 clone 的大小
 
# 首次 add 前的准备
git rm -r --cached <dir> && git commit -m "清空 prefix 目录"

📊 选型

需求选择
外部贡献者 / 开源被 fork,clone 命令你控制不了subtree
需要严格版本锁定,且只在自己团队内用submodule
只是想用一份代码,能接受手工同步直接 vendor(不加任何 git 机制)
消费的是发布物而非源码包管理器,跟 git 机制无关
多个仓库要频繁一起改、需要原子提交monorepo

💡 对本站 .quartz/ 的判断

本站 .quartz/ 用的是第三种方案:git clone 进来 + rm -rf .git(见 Quartz 5 配置指南第 1 节),把 Quartz 源码彻底 vendor 成主仓库的普通文件。

效果上接近 subtree add 之后的状态,差异只有两处:

本站现状subtree
代码在主仓库✅✅
历史无(clone 的历史随 .git 删掉)有,但压成一个 commit
拉上游npx quartz pullgit subtree pull --prefix=.quartz ... --squash
改本地文件正常改,pull 时解冲突同左,且更乱

结论:不值得转。 npx quartz pull 已经覆盖了 subtree 的唯一增量价值(同步上游),而 subtree 还要额外背两个成本——跟 Quartz 自带 pull 机制打架(它依赖 quartz/.git 的 remote 配置,而 subtree 已经把源码并进主仓库了),以及本地改动和上游更新混在一起无法区分。

顺带一个观察:本站能扛住 vendor 的关键,是 Quartz 自带的 .gitignore 跟着源码一起被 vendor 了——.quartz/.gitignore:12 的 .quartz/ 挡掉了 .quartz/.quartz/(插件缓存),根 .gitignore 又挡住 node_modules/ 和 public/。换成 subtree 或裸 vendor 拷贝上游代码时,这层忽略规则不会自动跟着来,得手动补。


🔗 相关


最后更新:2026-10-11