在前端日常开发中,我们经常git来当做代码版本管理工具,使用中基本都是一个项目一个Git仓库的形式,那么当我们的代码中碰到了业务级别的需要复用的代码,我们一般怎么做呢?
我们大致的考虑一下,一般有两种方案:
- 抽象成NPM包进行复用
- 使用Git的子仓库对代码进行复用
在涂鸦的小程序业务场景开发中,两个程序中有部分页面是重叠的,开发过程中重叠部分如果开发两套代码会浪费不少的人力,考量之后决定使用Git子模块的方式进行开发,父级仓库依赖两个公共的子模块,子模块本身和父级仓库一同进行开发,避免了版本问题和重复开发的问题。
我们在下面介绍的子仓库的使用场景基本都是如下的开发方式:
多个父级仓库都依赖同一个子仓库,但是子仓库自身不单独进行修改,而是跟随父级项目进行更新发布,其他依赖子仓库的项目只负责拉取更新即可。
那么什么是Git的子仓库呢?
通俗上的理解, 一个Git仓库下面放了多个其他的Git仓库,其他的Git仓库就是我们父级仓库的子仓库。
在正式开始介绍git的子仓库之前,我们要提前认识到一点,在刚开始使用Git子仓库的时候,如果不是很了解底层原理,很可能会导致使用子仓库出现云里雾里的现象,搞不清楚是父级仓库先提交,还是子仓库先提交,所以在本教程中,我们会先介绍子仓库的两种使用方式,然后携带一些子仓库的Git底层的分析,让大家对子仓库有一个更加全面的认识。
Git两种子仓库使用方案
1. git submodule
2. git subtree
我们按照顺序分别演示这两种子仓库的使用方式,方便大家深入理解两种子仓库的使用方式:
1. git submodule(子模块)
Git子模块允许我们将一个或者多个Git仓库作为另一个Git仓库的子目录,它能让你将另一个仓库克隆到自己的项目中,同时还保持提交的独立。
我们演示一下git submodule
的使用方法:
为了方便后续对两种子仓库的原理进行讲解,我们会详细的描述git的相关操作步骤
1.1 开始使用子模块
使用git init --bare
在本地创建两个裸仓库,分别表示主仓库和依赖的子仓库,我们将主仓库命名为main
,依赖的子仓库命名为lib
, git subtree
使用同样的初始化方法,下文不再赘述。
# 为了方便演示,我们使用/path/to/repos代表你当前开发的绝对路径
# 比如笔者的/path/to/repos代表/Users/userName/Documents/work
git --git-dir=/path/to/repos/main.git init --bare # 初始化主仓库
git --git-dir=/path/to/repos/lib.git init --bare # 初始化子仓库
# 本地拉取到这两个仓库
git clone /path/to/repos/main.git
git clone /path/to/repos/lib.git
# 我们分别对这两个仓库进行一次提交
cd main
echo "console.log('main');" > index.js
git add .
git commit -m "feat: 父级仓库创建index.js"
git push
cd ../lib
echo "console.log('utils');" > util.js
git add .
git commit -m "feat: 子仓库创建util.js"
git push
初始化结束两个子仓库后,我们想让main
主仓库能够使用lib
仓库的代码进行后续的开发,使用git submodule add
的命令后面加上想要跟踪项目URL来添加新的子模块(本文中的lib
仓库)。
# 首先进入到main的工作目录下
cd main
# 添加lib模块到main仓库下的lib同名目录
git submodule add /path/to/repos/lib.git
默认情况下,子模块会被添加到项目的子模块同名的目录下,如果想放到其他目录. 在add
命令的结尾跟上放置目录的相对路径即可。
执行完上述命令后,我们查看main
仓库下当前的目录结构:
tree
.
├── index.js
├── .gitmodules
└── lib
└── util.js
我们发现lib
仓库已经被放到main
仓库下的lib
目录下面了,同时还要注意的是,Git为我们创建了一个.gitmodules
文件,这个配置文件中保存了子仓库项目的URL和在主仓库目录下的映射关系:
cat .gitsubmodules
[submodule "lib"]
path = lib
url = /path/to/repos/lib.git
执行git status
发现有了新的文件
git status
On branch master
Your branch is up to date with 'origin/master'.
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)
new file: .gitmodules
new file: lib
我们对main
仓库进行一次提交:
git add .
git commit -m "feat: 增加子仓库依赖"
git push
操作结束后,我们的main
仓库就依赖了lib
仓库的代码并且已经上传到了云端的仓库当中,那么别人应该怎么去克隆包含子模块的项目呢?
1.2 克隆含有子项目的仓库
当我们正常克隆main
项目的时候,我们会发现,main
仓库中虽然包含lib
文件夹,但是里面并不包含任何内容,仿佛就是一个空文件夹:
git clone /path/to/repos/main.git
Cloning into 'main1'...
done.
cd main
tree # 使用tree命令查看当前目录,省略隐藏文件
.
├── index.js
└── lib
此时你需要运行git submodule
的另外两个命令,不需要担心,submodule
的命令不会太多。
首先执行git submodule init
用来初始化本地配置文件,也就是向.git/config
文件中写入了子模块的信息。
git submodule update
则是从子仓库中抓取所有的数据找到父级仓库对应的那次子仓库的提交id并且检出到父项目的目录中。
git submodule init
Submodule 'lib' (/path/to/repos/lib.git) registered for path 'lib'
git submodule update
done.
Submodule path 'lib': checked out '40f8536319ede421cfd9ca9f9904b5106946e8ec'
现在我们查看main
仓库下的目录结构,会发现和我们之前的提交的结构保持一致了,我们成功的拉取到了父级仓库和相关依赖子仓库的代码。
tree
.
├── index.js
└── lib
└── util.js
上述命令着实有些麻烦,有没有简单一些的命令能够直接拉取整个仓库的代码的方式呢? 答案是有的,我们使用git clone --recursive
,Git会自动帮我们递归去拉取我们所有的父仓库和子仓库的相关内容。
git clone --recursive /path/to/repos/main.git
Cloning into 'main'...
done.
Submodule 'lib' (/path/to/repos/lib.git) registered for path 'lib'
Cloning into '/path/to/repos/main/lib'...
done.
Submodule path 'lib': checked out '40f8536319ede421cfd9ca9f9904b5106946e8ec'
1.3 在主仓库上进行协同开发
我们在main
仓库下对lib
文件夹做了一些修改,然后我们想提交父仓库(main
)和子仓库(lib
)的修改,此时首先我们应该先提交子仓库的修改。
cd lib
当我们执行完上述命令后发现,lib
目录竟然包含了一整个完整的git仓库,甚至包含了.git
目录。
但是我们也发现当前不在lib
的master
分支上,而是在一个游离分支上面,这个游离分支的hash正式lib
仓库的master
分支的hash值,这正是git submodule
为我们所做的, Git不关心我们开发的分支,而只是去拉取子仓库对应的commit
提交。
所以我们需要先切换到正常分支, 然后正常操作git仓库一样去进行子仓库的提交。
git add .
git commit -m "子仓库进行修改"
git push
子仓库提交结束后,我们回到main
仓库的主目录下,执行git status
:
git status
On branch master
Your branch is up to date with 'origin/master'.
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git checkout -- <file>..." to discard changes in working directory)
modified: lib (new commits)
no changes added to commit (use "git add" and/or "git commit -a")
我们发现本次的Git的status和以往有些不一样的地方,Git并没有告诉我们当前到底修改了什么文件,而是说lib
下有一次新的提交,我们记住这个点,正常将主仓库进行提交并且push到云端仓库即可。
对git submodule
使用下来发现, submodule本身就是一个大的Git仓库下包含了多个子的Git仓库,我们修改之后,首先对每个子仓库进行了提交,然后父级仓库就会记录下每个子仓库的提交,然后正常提交父级仓库即可,拉取也是同样的过程,如果是在子仓库的分支上开发,也是先拉取子仓库,随后拉取父级仓库的更新,此处不再赘述。
如果觉得对每个子仓库进行提交繁琐的话,git sumodule foreach
就可以解决你这个烦恼:
# main目录下
git submodule foreach git pull
我们对所有的子仓库拉取了一次最新的代码, foreach
后面使用的就是你要对子模块使用的git命令。
那么还有一个问题,我们在修改了子仓库提交后,回到父级仓库执行git status
后为什么git不像以前一样告诉我们具体的文件更新信息呢,而是给出了modified: lib (new commits)
这样一串奇怪的信息,而这正式git submodule
的底层实现原理。
1.4 git submodule
原理分析
我们知道Git底层大致依赖了四种对象,构成了Git对于文件内容追踪的基础:
blob: 二进制大文件,可以通俗理解为对文件的修改
tree: 记录了blob对象和其他tree对象的修改,通俗理解为目录
commit: 提交对象,记录了本次提交的tree对象和父类的commit对象以及我们的提交信息
tag: 我们对当前提交记录版本的对象
更加详细的内容请参考深入理解Git,阅读后更容易理解后续知识点哦~
我们此处需要依赖一个print_all_object
的工具函数,它会帮助我们将git仓库下的这四种对象按照反向提交历史的排序展现出来,可以将它放在环境变量下方便全局使用:
#!/bin/bash
print_all_object() {
for object in `git rev-list --objects --all | cut -d ' ' -f 1`; do
echo 'SHA1: ' $object
git cat-file -p $object
echo '-------------------------------'
done
}
print_all_object
我们在main
仓库下执行print_all_object
:
# 此时处于我们刚对子模块提交的那个时间点
# 对部分长的hash进行了截取处理,不影响阅读观感
print_all_object
SHA1: a1cfd26e
tree c77ba9c2
parent ab118b8
feat: 增加子仓库依赖
-------------------------------
SHA1: ab118b8
tree f5771cd
feat: 父级仓库创建index.js
-------------------------------
SHA1: c77ba9c2
100644 blob d8c9fb4 .gitmodules
100644 blob ddd81ae index.js
160000 commit 40f8536 lib
-------------------------------
SHA1: d8c9fb4
[submodule "lib"]
path = lib
url = /path/to/repos/lib.git
-------------------------------
SHA1: ddd81ae
console.log('main');-------------------------------
SHA1: f5771cd
100644 blob ddd81ae index.js
-------------------------------
我们查看feat: 增加子仓库依赖
此次commit
对象的tree
对象,发现内容如下:
SHA1: c77ba9c
100644 blob d8c9fb456 .gitmodules
100644 blob ddd81aef index.js
160000 commit 40f85363 lib
index.js
文件是blob
对象,对应的file mode是100644,但是对于lib
子仓库的确是一个commit
对象, file mode为160000,这是Git中一种特殊的模式,表明我们是将一次提交的commit
记录在Git当中,而非将它记录成一个子目录或者文件。
而这正式git submodule
的核心原理,Git在处理submodule
引用的时候,并不会去扫描子仓库下的文件的变化,而是取子仓库当前的HEAD
指向的commit
的hash值,当我们对子仓库进行了更改后,Git获取到子模块的commit
值发生变化,从而记录了这个Git指针的变化。
在暂存区所以我们才发现了new commits
这种提示语,Git并不关心子模块的文件如何变化,我只需要在当前提交中记录子模块的commit的hash值即可,之后我们从父级仓库拉取子仓库的时候,Git拉取了本次提交记录中的子模块的hash值对应的提交,就还原了我们的整个仓库的代码。
1.5 git submodule
注意点
虽然使用git submodule
为我们的开发带来了很多便利,但是随之而来也会导致一些比较容易犯的错误,整理出来,防止大家采坑:
- 当子模块有提交的时候,没有push到远程仓库, 父级引用子模块的commit更新,并提交到远程仓库, 当别人拉取代码的时候就会报出子模块的commit不存在
fatal: reference isn’t a tree
。 - 如果你仅仅引用了别人的子模块的游离分支,然后在主仓库修改了子仓库的代码,之后使用
git submodule update
拉取了最新代码,那么你在子仓库游离分支做出的修改会被覆盖掉。 - 我们假设你一开始在主仓库并没有采用子模块的开发方式,而是在另外的开发分支使用了子仓库,那么当你从开发分支切回到没有采用子模块的分支的时候,子模块的目录并不会被Git自动删除,而是需要你手动的删除了 。