Skip to content

子模块 vs 子树

概述

Git 提供了两种管理外部依赖的方式:子模块(Submodule)和子树(Subtree)。两者各有优缺点,选择合适的方式取决于项目需求和团队工作流程。

两者对比

基本概念对比

特性子模块 (Submodule)子树 (Subtree)
存储方式引用外部仓库内嵌代码到主仓库
配置文件需要 .gitmodules无需额外配置
历史管理独立历史可选择合并历史
克隆方式需要额外初始化直接克隆即可
版本控制指向特定提交代码直接包含

命令对比

操作子模块子树
添加git submodule add <url> <path>git subtree add --prefix=<path> <url> <branch>
初始化git submodule init无需
更新git submodule updategit subtree pull --prefix=<path> <url> <branch>
克隆git clone --recursivegit clone
状态查看git submodule status无直接命令

存储结构对比

子模块结构

project/
├── .gitmodules          # 子模块配置
├── libs/
│   └── library/         # 子模块目录(引用)
│       └── .git         # 指向主仓库的 .git/modules
└── .git/
    └── modules/
        └── libs/
            └── library/ # 子模块的实际 Git 数据

子树结构

project/
├── libs/
│   └── library/         # 子树目录(实际代码)
│       ├── src/
│       └── README.md
└── .git/                # 所有代码都在同一个仓库

各自优缺点

子模块优点

仓库体积小

bash
# 子模块只存储引用,不存储实际代码
# 主仓库体积不会因依赖而增大

独立版本控制

bash
# 子模块保持独立的提交历史
cd libs/library
git log  # 查看子模块自己的历史

精确版本锁定

bash
# 主项目记录子模块的确切提交
git submodule status
# abc1234 libs/library (v1.2.0-12-gabc1234)

多项目共享

bash
# 多个项目可以引用同一个子模块
# 子模块更新后,各项目可以独立决定是否更新

子模块缺点

操作复杂

bash
# 克隆后需要初始化
git clone <url>
git submodule init
git submodule update

# 或者
git clone --recursive <url>

容易出错

bash
# 忘记更新子模块
git pull  # 只更新主项目
# 子模块可能不匹配

# 子模块处于游离 HEAD 状态
# 开发者可能不知道如何在子模块中工作

分支切换问题

bash
# 切换分支后子模块可能不匹配
git checkout feature-branch
# 需要手动更新子模块
git submodule update

CI/CD 配置复杂

yaml
# 需要额外配置
steps:
  - uses: actions/checkout@v3
    with:
      submodules: recursive

子树优点

操作简单

bash
# 克隆后直接可用
git clone <url>
# 所有代码都已包含

# 添加简单
git subtree add --prefix=libs/library <url> main --squash

无需额外配置

bash
# 不需要 .gitmodules 文件
# 不需要初始化命令
# 代码直接存在主仓库中

离线工作

bash
# 子树代码在本地
# 不需要网络连接也能工作
# 不依赖外部仓库的可用性

代码可见

bash
# 代码直接在目录中
# 可以直接编辑和查看
# 不需要进入子模块目录

子树缺点

仓库体积大

bash
# 所有依赖代码都在主仓库
# 仓库体积会增大
# 克隆时间变长

历史复杂

bash
# 不使用 --squash 时历史复杂
git log --oneline | wc -l
# 可能包含大量子树的提交

更新复杂

bash
# 推送修改到上游较复杂
git subtree push --prefix=libs/library <url> main
# 需要重新计算历史

重复代码

bash
# 多个项目各自包含子树代码
# 更新需要分别操作
# 没有统一的版本管理

使用场景选择

选择子模块的场景

场景一:大型项目依赖管理

bash
# 多个项目共享同一个大型库
# 只需要存储引用,节省空间

# 项目 A
git submodule add https://github.com/company/shared-lib.git libs/shared

# 项目 B
git submodule add https://github.com/company/shared-lib.git libs/shared

场景二:独立开发的组件

bash
# 组件有独立的开发周期和发布流程
# 需要精确控制版本

git submodule add -b v2.0 https://github.com/user/ui-components.git libs/ui

场景三:私有依赖

bash
# 私有仓库作为依赖
# 需要权限控制

git submodule add git@github.com:company/private-lib.git libs/private

场景四:频繁更新的依赖

bash
# 依赖更新频繁,需要独立跟踪
git submodule update --remote libs/library

选择子树的场景

场景一:稳定的第三方库

bash
# 第三方库稳定,很少更新
# 直接嵌入代码,简化管理

git subtree add --prefix=libs/jquery https://github.com/jquery/jquery.git main --squash

场景二:需要修改依赖

bash
# 需要定制化修改依赖
# 修改直接在主仓库中

git subtree add --prefix=libs/framework https://github.com/user/framework.git main --squash
# 直接修改 libs/framework 中的代码

场景三:简化团队协作

bash
# 团队成员 Git 技能水平不一
# 使用子树减少操作复杂度

# 克隆即用,无需额外命令
git clone <url>

场景四:离线开发环境

bash
# 开发环境网络受限
# 子树代码本地可用

git subtree add --prefix=libs/library <url> main --squash
# 之后可以离线工作

最佳实践

子模块最佳实践

1. 使用标签锁定版本

bash
cd libs/library
git checkout v1.2.0
cd ../..
git add libs/library
git commit -m "锁定 library 到 v1.2.0"

2. 文档化子模块

markdown
## 子模块说明

初始化命令:
git submodule update --init --recursive

更新命令:
git submodule update --remote

3. 使用钩子自动更新

bash
# .git/hooks/post-checkout
#!/bin/bash
git submodule update --init --recursive

4. CI/CD 配置

yaml
# GitHub Actions
- uses: actions/checkout@v3
  with:
    submodules: recursive
    token: ${{ secrets.GITHUB_TOKEN }}

子树最佳实践

1. 使用 --squash

bash
# 保持历史简洁
git subtree add --prefix=libs/library <url> main --squash
git subtree pull --prefix=libs/library <url> main --squash

2. 使用远程别名

bash
git remote add library <url>
git subtree add --prefix=libs/library library main --squash
git subtree pull --prefix=libs/library library main --squash

3. 记录子树信息

markdown
## 子树依赖

| 名称 | 路径 | 版本 | 更新命令 |
|------|------|------|----------|
| library | libs/library | v1.2.0 | git subtree pull --prefix=libs/library library main --squash |

4. 定期更新

bash
#!/bin/bash
# update-subtrees.sh
git subtree pull --prefix=libs/library library main --squash
git subtree pull --prefix=libs/utils utils main --squash

迁移指南

从子模块迁移到子树

bash
# 1. 删除子模块
git submodule deinit -f libs/library
git rm -f libs/library
rm -rf .git/modules/libs/library

# 2. 添加子树
git subtree add --prefix=libs/library <url> main --squash

# 3. 提交更改
git commit -m "从子模块迁移到子树: library"

从子树迁移到子模块

bash
# 1. 删除子树目录
git rm -rf libs/library
git commit -m "移除子树: library"

# 2. 添加子模块
git submodule add <url> libs/library

# 3. 提交更改
git commit -m "从子树迁移到子模块: library"

决策流程图

开始选择


是否需要多项目共享?

    ├── 是 ──→ 子模块

    └── 否


    是否需要独立版本控制?

        ├── 是 ──→ 子模块

        └── 否


        团队 Git 技能水平?

            ├── 较低 ──→ 子树

            └── 较高


            是否需要修改依赖?

                ├── 是 ──→ 子树

                └── 否


                仓库体积是否敏感?

                    ├── 是 ──→ 子模块

                    └── 否 ──→ 子树

总结

选择因素推荐方案
多项目共享依赖子模块
需要独立版本控制子模块
团队 Git 技能较低子树
需要修改依赖源码子树
仓库体积敏感子模块
简化操作流程子树
离线开发环境子树
频繁更新依赖子模块

最终建议

  • 小型项目、简单依赖:使用子树
  • 大型项目、复杂依赖:使用子模块
  • 团队协作:根据团队技能水平选择
  • 混合使用:可以在同一项目中同时使用两种方式