介绍面向复杂工程的简单化 Git 分支依赖管理方案。我们对子模块的使用进行了简化,避免了由于漏提交子模块 commit id 或子模块代码导致无法更新或更新错误的情况。

需求描述

我们尝试使用 Git 来维护一个项目的代码。这个项目的结构比较复杂:

  • 项目包含由多个子模块,每个子模块是一个独立的 Git 仓库,子模块还允许继续嵌套包含子模块。 例如,主工程依赖 common、framework、react_native 等多个子模块,而 react_native 子模块又依赖 node_modules、HFCommon、HFModules 等多个嵌套子模块。

1
2
3
4
5
6
7
8
9
10
11
[-] app_android/
|-[+] HFUIKit
|-[+] channel
|-[+] common
|-[+] framework
|-[+] hybrid
|-[+] messagecenter
|-[-] react_native
|-[+] HFCommon
|-[+] HFModules
|-[+] node_modules

  • 主工程和子模块允许存在多个分支,且相互之间有依赖关系。例如,主工程的 jilin 分支同时依赖 common 子模块的 master 分支,以及 framework 子模块的 jilin 分支。

Git submodule 的问题

Git 提供了 submodule 来支持子模块的需求,使用它可以很方便的将多个独立仓库包含到同一个主工程中:

1
2
3
$ git init
$ git submodule add http://xxx.xxx/common.git
$ git submodule add http://xxx.xxx/framework.git

Git submodule 还支持嵌套添加子模块:

1
2
3
4
5
$ git submodule add http://xxx.xxx/react_native.git
$ cd react_native
$ git submodule add http://xxx.xxx/HFCommon.git
$ git submodule add http://xxx.xxx/HFModules.git
$ git submodule add http://xxx.xxx/node_modules.git

通过子模块,这些子模块既可以各自独立的修改和提交代码,又可以将改动作用到依赖它的父工程。这听起来是个很棒的特性,然而 Git submodule 也存在着一些让人抓狂的坑。

首先,主工程并不直接跟踪子模块的代码,而仅仅只跟踪子模块的 commit id 的改动。在执行 git submodule update 更新子模块代码时,Git 就是根据主工程所维护的 commit id 来更新子模块到指定状态的。

1
2
3
4
5
6
7
8
9
bash-3.2$ git diff react_native
diff --git a/react_native b/react_native
index 3a9c5b1..ad68a28 160000
--- a/react_native
+++ b/react_native
@@ -1 +1 @@
-Subproject commit 3a9c5b14c45b199e2e6863d2b6da22dabc2a54f5
+Subproject commit ad68a28c13d4196df531c7df8523d07358288297
(END)

因此,如果你只在子模块中修改并提交了代码,而没有到主工程上面再把子模块的 commit id 提交一下,其他人拉取工程代码的时候会发现子模块的代码依然停留在老的 commit id 所指向的状态。对于嵌套子模块,这种工作尤为繁琐,提交代码后要逐层往上提交 commit id ,否则其他人无法正确更新代码。

其次,如前面所说,使用 git submodule update 更新子模块后,子模块将被切换到一个指向父工程维护的 commit id 所指定的游离状态:

1
2
3
4
5
6
7
bash-3.2$ git submodule update react_native
bash-3.2$ cd react_native
bash-3.2$ git branch
* (detached from 3a9c5b1)
master
jilin
TaiShan

一旦代码处于游离分支,你就要时刻警惕在游离分支上的提交有没有即时合并到非游离分支上。如果你直接在游离分支上开发并提交了代码,之后在父工程里再次 git submodule update ,你所有未合并的提交都会丢失!

最后还有一个非常麻烦,但也极容易出现的问题:如果团队里有人只提交了主工程该子模块的 commit id ,却忘了进入该模块提交模块真正的代码,那么当推送到中央仓库之后,其他人就会因为找不到与该 commit id 对应的代码而无法正确更新代码:

1
2
3
4
bash-3.2$ git submodule update
error: pathspec 'ad68a28c13d4196df531c7df8523d07358288297' did not match any file(s) known to git.
Did you forget to 'git add'?
Unable to checkout 'ad68a28c13d4196df531c7df8523d07358288297' in submodule path 'react_native'

对于熟练的用户,这些坑自然可以轻松越过。但考虑到团队里大都是 Git 新手,我们发现子模块的引入对他们造成了很大的负担,频繁出现子模块代码没有更新到最新状态,或者更新出错的情况。

简单解决方案

经过考虑,我们决定对子模块的使用做些简化:

  1. 所有子模块不再根据父工程的 commit id 更新代码,而是直接更新到主工程所依赖的分支的最新一次提交;
  2. 由于 commit id 不再用来更新代码,因此可以禁止直接提交子模块的 commit id ,避免出现只提交子模块 commit id 而忘记提交子模块代码的情况。

造个轮子:fmanager

为了达到第一个目的,我们自己写了个专用的管理工具 fmanager 。目前它一共支持如下几个功能,并且在不断扩展中:

1
2
3
4
5
6
7
8
9
fmanager pull #更新当前分支的主工程,并将每个子模块的代码更新到指定分支的最新状态。
fmanager update # ./fmanager pull 的别名
fmanager checkout <分支名> # 切换到某个主工程分支,同时完成子模块的代码切换。
fmanager submodule update <模块名列表> # 更新指定子模块的代码到所处分支的最新状态。
fmanager showbranch # 查看当前主工程和所有子模块的所属分支。
fmanager status # 查看当前主工程和所有子模块的修改状态。
fmanager log # 查看当前主工程和所有子模块的当前分支/标签和最新提交。
fmanager cherry-pick <commit id> <分支列表> cherry-pick 某个 commit id 到分支列表。
fmanager cherry-push <commit id> <分支列表> cherry-pick 某个 commit id 到分支列表,并推送这些分支。

这个工具直接内置在主工程的根路径,并且接受一个 json 格式的配置文件 modules.json ,该配置文件大概长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"sub": {
"app": {"branch": "master_dev"},
"common": {"branch": "master_dev"},
"fmall": {"branch": "master"},
"framework": {"branch": "jilin"},
"fund": {"branch": "master_dev"},
"hybrid": {"branch": "master_dev"},
"messagecenter": {"branch": "master"},
"property": {"branch": "master"},
"safetykeyboardnew": {"branch": "master"},
"scores": {"branch": "master"},
"thirdparty": {"branch": "master_dev"},
"react_native": {"branch": "jilin"},
"react_native/HFModules": {"branch": "jilin"},
"react_native/HFCommon": {"branch": "master_dev"},
"react_native/node_modules": {"branch": "master"}
}
}

不同的主工程分支,modules.json 配置文件的内容允许不同,且每个模块都允许指定不同分支。对于嵌套子模块,我们通过加上父模块前缀来做标识。

当使用 fmanager 切换分支时,fmanager 将首先完成主工程的分支切换,然后读入该分支下的 modules.json ,再根据 modules.json 的配置逐个切换到各自模块的指定分支。

使用 fmanager 更新工程和切换分支相似,只是顺便完成了子模块的 git pull 操作。

这样的子模块管理策略看起来有点“激进”:永远使用分支最新的代码状态。为了保证稳定性,我们还支持在 modules.json 中使用 tag :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"sub": {
"app": {"tag": "2.0.1"},
"common": {"tag": "2.0.1"},
"fmall": {"tag": "2.0.1"},
"framework": {"tag": "jilin-2.0.1"},
"fund": {"tag": "2.0.1"},
"hybrid": {"tag": "2.0.1"},
"messagecenter": {"tag": "2.0.1"},
"property": {"tag": "2.0.1"},
"safetykeyboardnew": {"tag": "2.0.1"},
"scores": {"tag": "2.0.1"},
"thirdparty": {"tag": "2.0.1"},
"react_native": {"tag": "jilin-2.0.1"},
"react_native/HFModules": {"tag": "jilin-2.0.1"},
"react_native/HFCommon": {"tag": "2.0.1"},
"react_native/node_modules": {"tag": "2.0.1"}
}
}

在项目后期,可以通过引用标签来保持整个工程的稳定性。同时,负责打包的机器每次打包时,都会顺便执行 fmanager log 产出一份包含当前所有子模块所处分支和最新一次 commit 的记录,方便追查问题。

加个钩子:pre-commit

要达到第二个目的,可以通过编写本地钩子 pre-commit 来实现。该钩子可以用来在 commit 前进行一些检查工作,并拒绝一些不合法的提交。针对我们的需求,可以写一个脚本检查提交中是否包含 commit id 的修改,如果有,就先重置那些修改再提交剩下的内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
#!/usr/bin/python
# coding: utf-8
import sys,os,io,subprocess,json,re
reload(sys)
sys.setdefaultencoding('utf8')
error_color = "\033[31m"
warning_color = "\033[33m"
normal_color = "\033[0m"
def getRootPath():
# 找到工程根目录
git_path = ".git"
pwd = os.getcwd()
while True:
if os.path.exists(os.path.join(pwd, git_path)) and os.path.isdir(os.path.join(pwd, git_path)):
return os.path.abspath(pwd)
else:
if (os.path.exists(os.path.join(pwd, "../"))):
pwd = os.path.join(pwd, "../")
else:
return None
def getSubmoduleNameAndPath():
'''获取子模块信息'''
root_path = getRootPath()
if root_path != None:
output = subprocess.Popen(['git submodule --quiet foreach \'echo $toplevel/$path\''], stdout=subprocess.PIPE, shell=True)
oc = output.communicate() #取出output中的字符串
submodule_dict = {}
for element in oc:
if element != None:
sb_list = element.split('\n')
for elem in sb_list:
if (elem != ""):
path = elem.strip()
name = path.replace(root_path+"/", '')
submodule_dict[name] = path
return submodule_dict
else:
print(warning_color)
print('''
警告:检测到当前工程不是 .git 工程,文件目录可能已经损坏!
''')
print(normal_color)
os.chdir(pwd)
return False
def checkSubModule():
'''防止提交子模块的 commit id'''
pwd = os.getcwd()
submodule_dict = getSubmoduleNameAndPath()
# 判断每个子模块是否修改了commit id
for module_name, module_path in submodule_dict.items():
os.chdir(os.path.join(module_path, ".."))
module_basepath = os.path.basename(module_path)
output = subprocess.Popen(['git diff --cached ' + module_basepath], stdout=subprocess.PIPE, shell=True)
oc = output.communicate()[0]
# 判断是否提交了 commit id 修改
if oc.find("-Subproject commit") >= 0 and oc.find("+Subproject commit") >= 0:
print(warning_color)
print('''
检查到子模块 %s 提交了 commit id,
我们强烈建议**不要**提交子模块的 commit id 改动!该 commit id 将被跳过提交。
''' % module_name)
print(normal_color)
res = subprocess.call(['git', 'reset', 'HEAD', module_basepath])
if res == 0:
print("子模块 " + module_name + " 的 commit id 重置成功!")
else:
print(error_color)
print("子模块 " + module_name + " 的 commit id 重置失败!")
print(normal_color)
os.chdir(pwd)
return False
os.chdir(pwd)
return True
if checkSubModule():
exit(0)
else:
exit(1)

钩子编写完,还得解决安装问题。所谓“安装”,即是将 pre-commit 复制到根目录中的 .git/hooks 目录中,并确保可执行。听起来好像很简单,实则不然:

  1. .git 里的内容并不会随仓库一同提交。需要另外想其他办法让团队其他人“上钩”,并保持钩子的同步。
  2. 父工程的钩子不会被继承到子模块,也就是说,如果你希望一个钩子在父工程和多个子模块中用到,那你需要为每个仓库都添加一次钩子。

fmanager 帮我们解决了第一个问题,由于我们用自己造的 fmanager 来更新工程代码,因此可以让 fmanager 在更新的时候自动完成钩子的安装。

而通过观察 .git 的文件结构,我发现每个子模块在 .git/modules 中各自拥有一个专属的数据目录。这个数据目录下也有一个 hooks 目录,该子模块的钩子就应该安装到这里。 如果有嵌套子模块,父模块的数据目录下还会有 modules 目录,并且可以一直这么嵌套下去。

解决这两个问题后,钩子顺利安装到了每个团队成员的仓库中,并且还能时刻保持同步。一旦有人试图提交 commit id 的修改,就会出现如下的错误:

利用 pre-commit 钩子实现跳过 commit id 提交

而其他内容的修改依然可以正常提交。

pre-commit 钩子非常有用,我们陆续又添加了诸如代码风格检查、json 合法性检查、commit 邮箱合法性检查等测试。

总结

通过本文的方法,我们对子模块的使用进行了简化,避免了由于漏提交子模块 commit id 或子模块代码导致无法更新或更新错误的情况。这么做看起来好像完全抛弃了子模块的 commit id ,但在下面的文章中,我将介绍一种自动更新子模块 commit id 的方法,该方法将利用 commit id 自动触发针对子模块的持续集成测试。

Comments