理解Git Submodules

一.背景

经常面临一些场景,想要把大代码库(repo)拆分成多个小的repo,例如:

  • 现有代码库体积庞大,且模块管理混乱,经常容易错改别人的东西

  • 某个模块需要单独构建,比如jQuery项目中的React试点、Node项目中的纯前端部分、Electron项目中的UI部分等等

  • 某个模块是黑盒依赖项,开发中仅依赖其构建后的版本,比如框架类库等

针对诸如此类的情况,一般有3种解决方案:

  • npm package:把依赖项拆出去作为npm package,代码库随之独立出去

  • monorepo:单repo体积庞大没关系,分模块管理好就行

  • git submodules:把依赖项拆分到多个独立repo,作为主repo的submodule

npm package

npm package的优势在于成熟的管理依赖机制,规范且易用,缺点是主项目只能通过package版本号获取独立模块的更新,在主项目需要与子模块联调的场景就会非常麻烦:

主项目:调不通啊
子模块:有点问题,我改一下...改版本号-构建-发布npm package
主项目:更新依赖,再试...还是调不通啊
子模块:还有点问题...

频繁发版比较蠢,可以本地修改构建再拷贝一份过去,但还是有些麻烦。当然,通常可以通过mock接口或数据把联调依赖拆解开,但有时候mock全套API成本比较高,而且假的势必没有真的好用

monorepo

monorepo主张不拆分repo,而是在单repo里统一管理各个模块的构建流程、版本号等等,并且鼓励改别人的代码

这在模块边界清晰、owner明确的项目中很合适(如React、Babel等),但实际应用中,业务repo很难保持清晰的模块边界与依赖关系,此时monorepo就变得理想化了

git submodules

git submodules提供了一种类似于npm package的依赖管理机制,包括添加、删除、更新依赖项等功能,区别在于前者所管理的依赖是子模块的源码,后者管理的是子模块的构建产物。在这一点上,git submodules与monorepo一致(都关心子模块的源码)

这样主项目需要与子模块频繁联调时的麻烦就不复存在了,因为主项目拉取到的submodules都是完整repo,可以直接修改-构建-提交

二.submodules与monorepo

从结构上看,submodules项目的主repo与monorepo很像,相当于把monorepo里的各模块抽离到了独立repo,仅记录主repo所依赖的各模块版本号(commit hash形式)

具体的,monorepo在单repo里存放所有子模块源码(packages/xxx/src),例如:

react/
  packages/
    react-dom/
      /src
    react-reconciler/
      /src
    ...

submodules只在主repo里存放所有子模块“索引”(repo url + branch name + commit hash),例如:

# 主repo的.gitmodules文件
[submodule "react-dom"]
    path = packages/react-dom
    url = https://github.com/facebook/react.git
    branch = master
[submodule "react-reconciler"]
    path = packages/react-reconciler
    url = https://github.com/facebook/react.git
    branch = master
...

主repo里只保留对应的空目录作为子模块的“坑”,并不存其放源码:

react/
  packages/
    react-dom/        # 空目录
    react-reconciler/ # 空目录

拉取所有submodules依赖后,实际目录结构如下:

react/
  packages/
    react-dom/
      /src
    react-reconciler/
      /src
    ...

主repo并不追踪子模块源码,仅记录其版本号(commit hash形式):

# 输出空,表示不追踪子模块src
$ git ls-tree -r master | grep packages/react-dom/src

# 查看主repo里被git追踪的子模块坑位
$ git ls-tree -r master | grep ' commit'
160000 commit 3edf340cee50fd4bc918a0a95b438a30447ae042 packages/react-dom
160000 commit 373f207b09a7bf900fa82c3188aeefdc9ce6146c packages/react-reconciler
...

P.S.git ls-tree的输出格式含义,见Output Format

三.具体用法

git submodule命令用来管理子模块:

$ git submodule --help
git-submodule - Initialize, update or inspect submodules
# 初始化
git submodule init
# 增
git submodule add
# 删
git submodule deinit
# 改(版本控制)
git submodule update

添加子模块依赖

$ cd ./react
# 添加依赖
$ git submodule add -b master https://github.com/path-to/react-dom.git src/packages/react-dom

会在主repo创建一个src/packages/react-dom空目录,作为子模块的坑位。实际上,add过程主要发生了3件事:

  • clone一份子模块repo到主repo的git缓存目录里,例如.git/modules/src/packages/react-dom

  • 创建坑位空目录,并把子模块repo的最新commit hash与之关联

  • 在主repo根目录按需创建.gitmodules文件,记录子模块repo地址(url),分支名(branch)以及坑位路径(path

然后提交这些子模块配置:

$ git add ./src/packages/react-dom ./.gitmodules
$ git commit -m "build: add react-dom submodule"
$ git push origin master

接下来本地拉取子模块完成初始化:

# 初始化子模块
$ git submodule update --init

会把子模块repo clone到src/packages/react-dom目录下,实际发生了2件事:

  • 检查缓存是否存在clone好的子模块repo(比如clone来的主repo并没add过,就不存在缓存),按需clone

  • 在子模块repo根目录创建.git/config,记录其repo地址(url

初始化子模块

在clone含有submodules的repo后,要进行初始化:

# 创建一些本地配置
$ git submodule init
# 拉取各子模块repo
$ git submodule update --init

也可以在clone主repo时,通过--recursive选项也能完成上面两步工作:

$ git clone git://gihub.com/path-to/main-repo.git --recursive

拉取子模块更新

更新所有子模块:

$ git submodule update --remote

会拉取子模块对应分支的最新代码,如有更新,占位目录的git状态会发生变化:

$ git status
modified:   src/packages/react-dom (new commits)

实际上是commit hash发生了变化:

$ $ git diff
diff --git a/src/packages/react-dom b/src/packages/react-dom
index 3edf340cee..d056efbc62 160000
--- a/src/packages/react-dom
+++ b/src/packages/react-dom
@@ -1 +1 @@
-Subproject commit 3edf340cee50fd4bc918a0a95b438a30447ae042
+Subproject commit d056efbc62cbf976b4ef83e70d7019fba4506e85

P.S.submodules里的commit hash相当于npm package的dependencies版本号

控制依赖项版本

想要更新主repo所依赖的子模块版本的话,提交这个commit hash变更:

$ git add src/packages/react-dom
$ git commit -m "build: update react-dom submodule"
$ git push origin master

否则不加--remote选项滚回当前依赖版本

$ git submodule update

改动子模块代码

子模块是独立repo,正常操作即可:

$ cd ./packages/react-dom
# 注意切分支,通常是detached状态
$ git checkout master
$ git add .
$ git commit -m 'feat: xxx'
$ git push origin master

之后,主repo就能通过git submodule update --remote拉取到最新版本,再由主repo决定是否要升级其依赖的子模块版本

对每个子模块执行相同的git命令

提供了foreach命令来对子模块进行批处理,例如:

# 进入每个子模块目录,并执行git stash
$ git submodule foreach 'git stash'
# 统一开feature分支
$ git submodule foreach 'git checkout -b featureA'

存在多个子模块依赖时,这个命令相当好用

P.S.关于submodules的更多用法,见7.11 Git Tools – Submodules

四.常见问题

子模块分支处于detached状态

每次执行git submodule update --remote后,子模块会处于detached状态,例如:

$ cd ./packages/react-dom
$ git branch
* (HEAD detached at ac4d1fc)
  master

设计如此,没有太好的解决办法

It’s also important to realize that a submodule reference within the host repository is not a reference to a specific branch of that submodule’s project, it points directly to a specific commit (or SHA1 reference), it is not a symbolic reference such as a branch or tag. In technical terms, it’s a detached HEAD pointing directly to the latest commit as of the submodule add.

因此在改动子项目代码之前,需要手动切换到master分支:

$ git checkout master
$ git add .
$ git commit -m 'feat: xxx'
$ git push origin master

本地子模块缓存

当子模块repo发生迁移时,进行git submodule add可能会遇到本地缓存的问题:

$ git submodule add ssh://XXX.XXX.XXX.XXX:XXXXX/opt/git/fdf.git projets/fdf
A git directory for 'projets/fdf' is found locally with remote(s): origin ssh://git@XXX.XXX.XXX.XXX:XXXXX/opt/git/fdf.git If you want to reuse this local git directory instead of cloning again from ssh://XXX.XXX.XXX.XXX:XXXXX/opt/git/fdf.git use the '--force' option. If the local git directory is not the correct repo or you are unsure what this means choose another name with the '--name' option.

需要先删掉原配置(第2第3步),再本地缓存的子模块信息(第1第4步):

# 1.删掉git缓存及物理文件
$ git rm --cached path_to_submodule
$ rm -rf path_to_submodule

# 2.删掉.gitmodules里该子模块的相关配置
$ vi .gitmodules
[submodule "path_to_submodule"]
    path = path_to_submodule
    url = https://github.com/path_to_submodule

# 3.删掉.git/config里该子模块相关配置
$ vi .git/config
[submodule "path_to_submodule"]
    url = https://github.com/path_to_submodule

# 4.删掉子模块缓存
$ rm -rf .git/modules/path_to_submodule

清理完成之后重新git submodule add即可

P.S.第4步中,子模块的缓存位置可以通过如下命令查看:

$ cat path_to_submodule/.git
gitdir: ../.git/modules/path_to_submodule

P.S.更多常见问题,见Using Git Submodules

参考资料

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*

code