开发中经常会遇到这样的情况:项目越来越大,一些通用的模块我们希望将他抽离出来作为单独的项目,以便其他项目也可以使用,或者使用一些第三方库,可能我们并不想将代码直接拷贝进我们的项目里面,而仅仅只是单纯的引用。这时问题来了,你想把他们当做独立的项目,同时又想在项目中使用另一个。
假设搭建自己的个人博客,然后使用了某个主题,而博客中的主题常以独立项目的形式提供。如果直接将主题项目代码复制到博客项目中,不仅丢弃了主题项目的维护历史,同时你将再也无法自由及时地合并上游的更新。这时你就需要在个人博客项目中引用主题项目。
基于Git一般有两种方式来解决这个问题:Git Submodule,Git Subtree
Git Submodule
Git Submodule允许你将一个Git仓库作为另一个Git仓库的子目录。它能让你将另一个仓库克隆到自己的项目中,同时还保持独立的提交。
添加子模块
将一个已存在的Git仓库添加为正在工作的仓库的子模块,可以使用git submodule add <repository> [<path>]
命令。以MyBlog博客项目中添加Hanscal主题为例:
# 默认情况下,子模块会将子项目放在一个与仓库同名的目录中比如Hanscal目录下
# 我们也可以通过在命令结尾添加一个path来指定放到相对于项目根目录的其他地方,下面是相对根目录themes/Hanscal下。
$ git submodule add /private/tmp/remote/Hanscal.git themes/Hanscal
如果这时运行git status
,你会注意到2件事情。
首先产生了新的.gitmodules
文件。该文件保存了子模块的url
与本地目录之间的映射,如果有多个子模块,该文件中就会有多条记录:
[submodule "themes/Hanscal"]
path = themes/Hanscal
url = /private/tmp/remote/Hanscal.git
虽然themes/Hanscal
是工作目录中的一个子目录,但Git将它视作一个子模块。当不在那个目录中时,Git并不会跟踪其内容,而是将其看作该仓库中的一个特殊提交。
其次还有个隐藏的变化在.git/config
中:
$ cat .git/config
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
ignorecase = true
precomposeunicode = true
[remote "origin"]
url = /private/tmp/remote/MyBlog.git
fetch = +refs/heads/*:refs/remotes/origin/*
[branch "master"]
remote = origin
merge = refs/heads/master
[submodule "themes/Hanscal"]
url = /private/tmp/remote/Hanscal.git
父项目的.git/config
文件中也保存了子模块的信息,所以你可以根据自己的需要,通过配置父项目.git/config
文件来覆盖.gitmodules
中的配置。如通过在本地执行git config submodule.themes/Hanscal.url <url>
来覆盖.gitmodules
中的url
。
克隆子模块
当你克隆一个含有子模块的项目时,默认会包含该子模块目录,但其中还没有任何文件。
$ git clone /private/tmp/remote/MyBlog.git # 该命令运行后主项目中不包含子模块
$ git submodule init # 用来初始化本地配置文件
$ git submodule update # 从该项目中抓取所有数据并检出父项目中列出的合适的提交,使themes/Ha处在和之前提交时相同的状态
# 还有一个简单方式,前面三个命令等同于下面这个命令
# 如果给git clone命令传递--recursive选项,它就会自动初始化并更新仓库中的每一个子模块。
$ git clone --recursive /private/tmp/remote/MyBlog.git
更新子模块
假如在当前开发中,父项目只是使用子项目并不时的获取更新。这时你可以进入到子模块目录中运行git fetch
与git merge
,合并上游分支来更新本地代码。
$ git fetch && git merge origin/master # 在子模块目录中手动抓取与合并
# 或者更简单的方式
$ git submodule update --remote # Git将会进入子模块然后抓取并更新,此命令默认更新并检出所有子模块仓库的master分支。不过你也可以通过path(相对路径)指定想要更新的子模块、想要更新的分支以及更新后进行的操作。
这时运行git status
,Git会显示子模块中有“新提交”,如果在此时提交,那么会将父项目锁定为子模块master
分支最新的代码。假如你希望在父项目上编写代码的同时又在子模块上编写代码,那又该如何处理呢?
当我们运行git submodule update
从子模块仓库中抓取修改时,Git将会获得这些改动并更新子目录中的文件,但是会将子仓库留在一个称作 “游离的 HEAD”的状态。这意味着没有本地工作分支(例如 “master”)跟踪改动。所以你做的任何改动都不会被跟踪。
一种方式进入每个必要的子模块并检出topic工作分支,使得子模块设置得更容易更新修改:
$ cd themes/Hanscal # 进入子模块
$ git checkout -b featureA # 检出子模块分支
然后,我们可以对子模块做些改动,使用git fetch
或者git pull
来更新代码,git merge
或者git rebase
合并改动,就像独立的项目开发一样。
另一种方式,也可以在父项目中使用git submodule update --remote --merge
或者git submodule update --remote --rebase
来合并代码。如:
$ git submodule update --remote --rebase
如果你忘记--rebase
或--merge
,Git会将子模块更新为服务器上的状态。并且会将项目重置为一个游离的HEAD状态。即便这真的发生了也不要紧,你只需回到目录中再次检出你的分支(即还包含着你的工作的分支)然后手动地合并或变基对应的分支(或任何一个你想要的远程分支)就行了。如果你做了一些与上游改动冲突的改动,当运行更新时Git会让你知道,然后你可以进入子模块目录中然后就像平时那样修复冲突。
提交子模块
如果我们在父项目中提交并推送但并不推送子模块上的改动,其他尝试检出我们修改的人会遇到麻烦,因为他们无法得到依赖的子模块改动。那些改动只存在于我们本地的拷贝中。
提交子模块的改动最简单的选项是进入每一个子模块中然后手动推送到远程仓库。然而git push
命令接受值为on-demand
的--recurse-submodules
参数,它会尝试为你这样做。
$ git push --recurse-submodules=on-demand # 在父项目中运行该命令,会提交所有子模块更新
遍历子模块
如果父项目中包含大量子模块,那我们一些通用的子模块操作,如更新子模块,将会变成巨大的工作量。幸好,Git提供了foreach
子模块命令。
假如,我们想要开始开发一个新的功能或者修复一些错误,并且需要在几个子模块内工作。这时我们可能需要创建一个新的分支,然后将所有子模块都切换过去。
$ git submodule foreach 'git checkout -b featureA'
子模块的问题
然而使用子模块还是有一些小问题:
- 在父项目中
git pull
并不会自动更新子模块,需要调用git submodule update
来更新子模块信息。如果忘记调用git submodule update
,那么你极有可能再次把旧的子模块依赖信息提交上去。 - 调用
git submodule update
并不会将子模块切换到任何分支,默认情况下子模块处于“游离的 HEAD”的状态。如果此时我们改动子模块而没有检出一个工作分支,那调用git submodule update
时你所做的任何改动都会丢失。 - Git子模块在父项目中维护所有依赖的子模块版本,当包含大量子模块时,父项目的更新将很容发生冲突,并且父项目的维护历史与所有子模块的维护历史相互交织,维护成本也会比较高。
Git Subtree
该命令使用Git的subtree merge
策略来得到类似git submodule
的结果。但本质上,它是将子项目的代码全部merge
进父项目。该命令不仅可以将其他项目合并为父项目的一个子目录,而且可以从父项目提取某个子目录的全部历史作为一个单独的项目。
相比Git Submodule
- 管理和更新流程比较方便
- 不再有
.gitmodules
文件 - 克隆仓库不再需要
init
和update
等操作 - 删除时不再像
git submodule
那样费劲
添加子项目
将一个已存在的Git仓库以Subtree方式添加为子项目可以使用git subtree add --prefix=<prefix> <repository> <ref>
命令,其中--prefix
选项指定了子项目对应的子目录,--squash
选项用以压缩Subtree的提交为一个,这样父项目的历史记录里就不会出现子项目完整的历史记录。我们还是以MyBlog添加Hanscal主题为例:
$ git clone /private/tmp/remote/MyBlog.git
$ cd MyBlog/
$ git subtree add --prefix=themes/Hanscal /private/tmp/remote/Hanscal.git master --squash
更新子项目
一段时间之后,子项目可能有大量新的代码,父项目也想使用这些代码。此时父项目的维护者只需执行下面命令,就可以将父项目中子项目对应目录里的内容更新为子项目最新的代码了:
$ git subtree pull --prefix=themes/Hanscal /private/tmp/remote/Hanscal.git master --squash
如果觉得每次都输入子项目完整的仓库url太麻烦,可以将子项目添加为追踪的仓库,然后再执行命令:
$ git remote add hanscal /private/tmp/remote/Hanscal.git
$ git subtree add --prefix=themes/Hanscal hanscal master --squash
提取子项目
当我们开发一个项目若干时间后,希望将某个目录单独出一个项目来开发,同时又保留这部分代码历史提交记录,使用git subtree split
可以很轻松的完成这个操作。以MyBlog分离Hanscal主题为例:
$ git subtree split --prefix=themes/Hanscal --branch hanscal # 将themes/Hanscal这个项目创建为分支hanscal,但没有切换到该分支
$ git branch -a # 查看所有分支,出现hanscal分支
$ git checkout hanscal # 切换到hanscal分支
$ git log # 查看上述过程log信息
其中--branch
指定将生成的历史提交记录保存到一个新的分支。
提交子项目
如果我们在使用子项目的过程中,对子项目做了一些改动,同时我们又希望子项目的其他使用者也能共享这些改动,此时可以将我们的改动提交到子项目的远程仓库中。
$ git subtree push --prefix=themes/Hanscal /private/tmp/remote/Hanscal.git master