Docs Library

Git Subtree 使用指南

解释 git subtree 的概念、与 submodule 的对比、添加和更新子树的操作,以及子树合并和拆分的高级用法。

适合谁看
  • 想先理解历史图再看命令的人
前置知识
  • 知道提交不是文件快照列表那么简单
常见风险
  • 把概念页当命令说明页使用

一句话理解

git subtree 允许你将一个仓库作为子目录嵌入到另一个仓库中,子树的内容会成为主仓库历史的一部分,而不是像 submodule 那样作为外部引用。

Subtree 与 Submodule 的对比

这是理解 git subtree 的关键起点。两种方式都能实现"一个仓库包含另一个仓库",但实现机制截然不同:

特性git subtreegit submodule
存储方式子仓库文件直接存在于主仓库中子仓库是独立仓库,主仓库只存引用(commit hash)
clone 后所有文件立即可用,无需额外操作需要 git submodule update --init 初始化
历史记录子树的历史会混入主仓库历史子仓库历史独立,主仓库只记录指向的 commit
修改子仓库可以直接在主仓库中修改,然后 push 回子仓库远端需要在子目录中单独操作
仓库大小主仓库包含子仓库所有文件,体积较大主仓库体积小,子仓库独立
复杂度操作简单,没有 submodule 的那些坑需要额外学习 submodule 的专用命令
共享使用其他项目不能直接引用你的 subtree 版本多个项目可以引用同一个 submodule 的同一个版本

何时选择 subtree

  • 你希望团队成员 clone 后立刻能用,不需要额外步骤
  • 子项目不大,不会对主仓库体积造成明显影响
  • 你经常需要修改子项目代码
  • 团队不熟悉或不喜欢 submodule 的复杂度

何时选择 submodule

  • 子项目很大(如大型框架、引擎)
  • 你需要精确控制子项目的版本
  • 多个独立项目共享同一个子项目
  • 子项目有独立的开发团队和发布周期
Subtree 的工作流程Subtree 将外部仓库的历史作为子目录嵌入到主仓库中。添加、更新和推送回子仓库都有专门的命令。
外部仓库
lodash.git (工具库)docs-site.git (文档站)shared-lib.git (共享库)
操作结果
add → 子仓库历史嵌入主仓库pull → 拉取子仓库更新到子目录push → 将子目录修改推送回子仓库
--squash 参数会将子仓库的多次提交合并为一次,保持主仓库历史简洁。

基本操作

添加子树

# 语法
git subtree add --prefix=<目录> <仓库URL> <分支> [--squash]

# 示例:将 lodash 库添加到 vendor/lodash 目录
git subtree add --prefix=vendor/lodash https://github.com/lodash/lodash.git main --squash

参数说明:

  • --prefix:子树在主仓库中的存放目录
  • <仓库URL>:子仓库的远程地址
  • <分支>:要引入的子仓库分支
  • --squash:将子仓库的所有提交压缩为一个提交(推荐,避免引入大量历史)

查看子树

# 列出子树目录中的文件
ls vendor/lodash/

# 查看子树相关的提交
git log --oneline -- vendor/lodash/

更新子树(拉取上游变更)

# 语法
git subtree pull --prefix=<目录> <仓库URL> <分支> [--squash]

# 示例:拉取 lodash 的最新变更
git subtree pull --prefix=vendor/lodash https://github.com/lodash/lodash.git main --squash

--squash 同样推荐,避免每次更新都引入完整历史。

推送子树变更(向子仓库上游推送)

# 语法
git subtree push --prefix=<目录> <仓库URL> <分支>

# 示例:将你对 lodash 的修改推回上游
git subtree push --prefix=vendor/lodash https://github.com/lodash/lodash.git my-fix-branch

注意:这要求你对子仓库有写入权限,通常用于你 fork 的子仓库。

子树拆分(Split)

git subtree split 是 subtree 最强大的功能之一。它能把主仓库中某个子目录的历史拆分成一个独立的分支,这个分支可以直接作为独立仓库推送。

拆分场景

当你决定将子目录提升为独立仓库时:

# 将 vendor/lodash 目录拆分为独立分支
git subtree split --prefix=vendor/lodash --branch=lodash-standalone

这会创建一个新分支 lodash-standalone,其中只包含 vendor/lodash/ 目录的文件和历史(路径已调整为根目录)。

推送到新仓库

# 拆分并推送到新远端
git subtree split --prefix=vendor/lodash --branch=lodash-standalone

# 添加新远端
git remote add lodash-origin https://github.com/yourname/lodash.git

# 推送拆分后的分支
git push lodash-origin lodash-standalone:main

重复拆分和推送

当你后续在主仓库中对子目录做了修改,可以再次拆分并推送:

# 再次拆分(Git 会记住上次拆分的起点)
git subtree split --prefix=vendor/lodash --branch=lodash-standalone

# 推送新变更
git push lodash-origin lodash-standalone:main

高级用法

不使用 --squash 添加子树

如果你需要保留子仓库的完整历史:

# 不带 --squash:保留完整历史
git subtree add --prefix=vendor/lodash https://github.com/lodash/lodash.git main

# 这会引入 lodash 的所有提交历史
git log --oneline | head -20

从已有的子目录创建子树

如果你已经在主仓库中有了一个目录,想把它关联到一个远端仓库:

# 先添加远端
git remote add -f lodash https://github.com/lodash/lodash.git

# 然后添加子树(使用已有目录)
git subtree add --prefix=vendor/lodash lodash main --squash

在子树中工作并提交

# 在子目录中直接修改
cd vendor/lodash
# 编辑文件...

# 在主仓库根目录提交
cd ../..
git add vendor/lodash/
git commit -m "fix: 修复 lodash 中的某个问题"

# 推送到子仓库远端
git subtree push --prefix=vendor/lodash https://github.com/yourname/lodash.git fix-branch

查看子树的提交差异

# 查看子树目录的变更
git log --oneline -p -- vendor/lodash/

# 比较两次提交间子树的变化
git diff HEAD~5..HEAD -- vendor/lodash/

工作流示例

场景:前端项目引入公共组件库

# 1. 添加公共组件库作为子树
git subtree add --prefix=libs/common-ui https://github.com/company/common-ui.git main --squash

# 2. 在日常开发中直接修改组件
git add libs/common-ui/
git commit -m "feat: 为 CommonButton 添加 loading 状态"

# 3. 定期同步上游更新
git subtree pull --prefix=libs/common-ui https://github.com/company/common-ui.git main --squash

# 4. 将修改贡献回上游
git subtree push --prefix=libs/common-ui https://github.com/yourname/common-ui.git feature/loading

场景:从单体仓库拆分微服务

# 1. 从单体仓库中拆分出 auth-service
git subtree split --prefix=services/auth --branch=auth-service-split

# 2. 创建新仓库并推送
cd /tmp
git init auth-service
cd auth-service
git pull /path/to/monorepo auth-service-split

# 3. 添加远端并推送
git remote add origin https://github.com/company/auth-service.git
git push -u origin main

# 4. 在单体仓库中保留子树关系
git subtree add --prefix=services/auth https://github.com/company/auth-service.git main --squash

常见问题

1. subtree pull 冲突

# 如果 pull 时发生冲突,手动解决后继续
git subtree pull --prefix=vendor/lib https://example.com/lib.git main --squash

# 如果有冲突,正常解决:
# 编辑冲突文件 → git add → git commit

# 注意:不要使用 git merge --abort,subtree 的 merge 需要特殊处理

2. 找不到上次拆分的 commit

# Git 通过 commit message 中的 "git-subtree-dir" 来跟踪
# 如果丢失,可以手动指定:
git subtree split --prefix=vendor/lodash --branch=lodash-split --rejoin

3. subtree push 失败

# push 失败通常是因为远端有新提交
# 先 pull 再 push
git subtree pull --prefix=vendor/lib https://example.com/lib.git main --squash
git subtree push --prefix=vendor/lib https://example.com/lib.git main

4. 子树太大影响性能

如果子树体积过大,考虑:

  • 使用 --squash 减少历史大小
  • 切换到 git submodule
  • 使用 git sparse-checkout 减少检出范围

调试技巧

查看子树相关的提交

# 搜索包含 "Subtree" 的提交信息
git log --grep="Subtree" --oneline

# 查看特定子树目录的所有变更
git log --oneline --follow -- vendor/lodash/

检查子树是否正确引入

# 确认子树目录存在
ls -la vendor/lodash/

# 确认子树目录被正确跟踪
git ls-tree HEAD vendor/lodash/ | head -5

注意事项

  1. --squash vs 完整历史:团队项目推荐 --squash,保持主仓库历史清爽
  2. 子树 push 需要写入权限:你只能推送到有权限的子仓库
  3. 子树目录路径变更:如果重命名了子树目录,后续操作需要使用新路径
  4. 合并冲突:子树的 merge commit 信息较长,冲突时需要仔细阅读
  5. 备份:对重要的子树操作,建议先创建备份分支

继续学习建议

  1. git submodule —— 对比理解两种子项目管理方式
  2. git sparse-checkout —— 部分检出大仓库
  3. monorepo 工具(Turborepo、Nx、pnpm workspace)