I. 何为 Git 子模块?
以前端项目为例,通常我们用 npm dependencies 来集成第三方库,或者将自己维护的多个项目中通用的组件抽取出来。
"devDependencies": {
"babel-eslint": "^8.2.3",
"base64-img": "^1.0.3",
"body-parser": "^1.17.2",
"colors": "^1.3.0",
"eslint": "^4.19.1",
"eslint-plugin-babel": "^5.1.0",
"express": "^4.15.3",
"fs-extra": "^3.0.1",
"fs-watch-tree": "^0.2.5",
"klaw-sync": "^2.1.0",
"less": "^2.7.2",
"lodash": "^4.17.4",
"node-file-eval": "^1.0.0",
"nodemon": "^1.11.0",
"postcss": "^6.0.5",
"precommit-hook": "^3.0.0",
"ws": "^5.1.1"
}
这种方式简单方便、支持广泛,适用于大部分情况;但是对于其中某些库来说,也存在一些痛点:
那么,基于以上几点,如果不得不将第三方源码手动拷贝到项目中,又会带来更多的问题:
一个虽然不一定是最好的,但可行的办法是:
submodule
子模块(submodule)允许你将一个 Git 仓库作为另一个 Git 仓库的子目录; 它能让你将另一个仓库克隆到自己的项目中,同时还保持提交的独立
简单的说,子模块的解决方案更像是上面两种的融合,类似于一种特区模式:代码既存在于主项目的子文件夹中,可以灵活的为我所用;在 Git 层面又是独立提交管理的,和主项目的 commit 时间线保持了完全的独立性。
如果第三方库发生了变化,那么项目中的子模块可以自由自主的选择 合并、变基、切换分支 等各种操作。比如一个通用组件作为子模块分别被公司中不同项目引用,则各个项目组做出的改进,最后都可以汇总到主组件库中,为大家所共享。
在当前项目中,添加已有的第三方库:
git submodule add 3RD_LIB_GIT_PATH
默认情况下,子模块会自动放入一个与其仓库同名的子目录中;在末尾也可以加一个自定义的路径参数。
同时项目中会出现一个新的 .gitmodules
配置文件,保存了一些映射关系:
[submodule "3RD_LIB_NAME"]
path = 3RD_LIB_NAME
url = 3RD_LIB_GIT_PATH
......
子模块所在的子目录是被 Git 特殊对待的 – 也就是说,当你不在此目录中时,Git 默认并不跟踪其中的内容,而是将其变动当成一种特殊的提交对待。
克隆含有子模块的项目时,对应的子目录其实默认是空的,需要额外的步骤。
默认做法是:
# 克隆主项目
git clone MAIN_PROJECT_GIT
# 初始化本地配置文件
git submodule init
# 抓取所有数据并检出父项目中列出的合适的提交
git submodule update
更简单一些的做法是在 clone 时加上参数:
git clone --recursive MAIN_PROJECT_GIT
在项目中使用子模块的最简单模式,就是只对其更新并享用最新版本,但并不修改之。
更新子模块的命令为:
git submodule update --remote
Git 默认会尝试更新所有子模块;如果子模块数量众多,也可以在以上命令中传入需要更新的子模块名称。
默认情况下,子模块并没有本地分支,而是会停留在一种特殊的 “detached HEAD” 模式下;要对其修改并被 Git 跟踪的话,就要先手动检出分支:
# 检出一个叫 stable 的分支
git checkout stable
然后从上游拉取新的内容,此时有两种选择:
# 选择A:合并
git submodule update --remote --merge
# 选择B:变基
git submodule update --remote --rebase
因为主项目并不会跟踪子模块中的变更,也就是说子目录中更改的具体业务文件不会在 push 时被自动发布;所以需要要求 Git 在推送主项目之前检查所有子模块是否已正确提交:
git push --recurse-submodule=check
根据上述检查结果,可以进入每个子模块并手动提交。
还有更简单的做法是自动完成这项操作:
git push --recurse-submodule=on-demand
此时会先推送子模块再推送主项目,如果前者失败整个流程将停止。
会遇到和其他人先后改动了同一个子模块的情况,也就是一个提交是另一个的直接祖先,那么 Git 会简单地选择之后的提交来合并,这样没什么问题。
不过,当两边同时修改,也就是子模块提交已经分叉的情况下,如果尝试合并,Git 会报 “merge following commits not found” 错误。
解决的方法有些麻烦,罗列如下:
# 得到试图合并的两个分支中记录的提交的 SHA-1 值
$ git diff
diff --cc 3RD_LIB_GIT_PATH
index eb41d76,c771610..0000000
--- a/3RD_LIB_GIT_PATH
+++ b/3RD_LIB_GIT_PATH
# 进入子模块目录
$ cd 3RD_LIB_GIT_PATH
# 基于 git diff 的第二个 SHA 创建一个分支
$ git branch my-try-merge-branch c771610
(3RD_LIB_GIT_PATH) $ git merge my-try-merge-branch
Auto-merging src/main.c
CONFLICT (content): Merge conflict in src/main.c
Recorded preimage for 'src/main.c'
Automatic merge failed; fix conflicts and then commit the result.
# 手动解决冲突
$ vim src/main.c
# 返回到主项目目录中
$ cd ..
# 再次检查 SHA-1 值
$ git diff
# 添加解决后的子模块记录
$ git add 3RD_LIB_GIT_PATH
# 提交合并
$ git commit -m "Merge Tom's Changes"
git rm –cached <子模块名称>
git status
,顶多只能知道子模块有变化,但具体是什么还要到子目录中再去运行一次git subtree
命令,从 git v1.8 后可用,官方推荐使用 subtree 代替 submodule,其并不需要保存 .submodule 这样的元信息。
subtree 用法如下:
# 其中-f意思是在添加远程仓库之后,立即执行fetch
git remote add -f <子仓库名> <子仓库地址>
# --squash意思是把subtree的改动合并成一次commit,这样就不用拉取子项目完整的历史记录。--prefix之后的=等号也可以用空格
git subtree add --prefix=<子目录名> <子仓库名> <分支> --squash
git fetch <远程仓库名> <分支>
git subtree pull --prefix=<子目录名> <远程分支> <分支> --squash
# 需要确认有写权限
git subtree push --prefix=<子目录名> <远程分支名> 分支