git-submodule实现

一、submodule功能

有时候,一个项目会引用一些外部模块,特别是一些开源项目之间经常会有依赖(或者被依赖)。svn可以通过external来引用外部库,git则通过submodule实现。
但是,git对于submodule的处理比较简单。事实上,git submodule是一个bash脚本文件,很多选项是通过内置的(在git源代码中以C语言实现并被编译到git二进制中的)submodule--helper命令来完成。尽管可能在git的man手册中找不到这个命令的说明,通过源码还是可以看到这个命令的;在命令行中通过git submodule--helper -h可以看到git内部支持这个命令。只是这个命令本身不建议终端用户直接使用,而是通过submodule这个封装之后的接口来调用相关功能。
例如,万物开始的git submodule add子命令只是一个在bash脚本(/usr/libexec/git-core/git-submodule文件)中实现的功能,在git二进制可执行文件中的submodule--helper中并没有对应的功能。

二、一个子模块需要什么信息

如果要确定一个子模块,我们能想到的最基本的信息包括
1、一个文件夹是否是一个子模块
在svn中,可以通过为文件夹设置属性来表示这个文件夹是一个外部引用。
2、它引用的repo地址
这个是显然的,需要知道引用的地址。
3、该repo历史提交记录
根据git的管理模式,需要有一个.git文件夹存储外部repo的历史版本信息。

三、如何知道一个文件夹是一个submodule

1、文件系统中的文件mode

从linux内核的定义可以看到,inode有4个bits(00170000)表示文件类型,从S_IFSOCK到S_IFIFO总共7个,已经超过了bit数(4个)。所以表示的时候不是每个类型都有一个单独的bit,而是所有的bit一起表示一个唯一的bit。或者说,这里列出的文件类型之间必然是互斥的,这一点从S_ISDIR之类的宏可以看到,它是取bit掩码之后判断4个bit整体情况而不是某个特定bit。
linux-2.6.21\include\linux\stat.h
#define S_IFMT 00170000
#define S_IFSOCK 0140000
#define S_IFLNK 0120000
#define S_IFREG 0100000
#define S_IFBLK 0060000
#define S_IFDIR 0040000
#define S_IFCHR 0020000
#define S_IFIFO 0010000
……
#define S_ISLNK(m) (((m) & S_IFMT) == S_IFLNK)
#define S_ISREG(m) (((m) & S_IFMT) == S_IFREG)
#define S_ISDIR(m) (((m) & S_IFMT) == S_IFDIR)
#define S_ISCHR(m) (((m) & S_IFMT) == S_IFCHR)
#define S_ISBLK(m) (((m) & S_IFMT) == S_IFBLK)
#define S_ISFIFO(m) (((m) & S_IFMT) == S_IFIFO)
#define S_ISSOCK(m) (((m) & S_IFMT) == S_IFSOCK)
在内核中同样可以看到,0160000这个数值的文件类型还没有被使用,所以当前的git判断如果一个文件的类型为S_IFLNK,就认为这个文件是一个submodule。
/*
* A "directory link" is a link to another git directory.
*
* The value 0160000 is not normally a valid mode, and
* also just happens to be S_IFDIR + S_IFLNK
*/
#define S_IFGITLINK 0160000
#define S_ISGITLINK(m) (((m) & S_IFMT) == S_IFGITLINK)
作为对比,常规linux文件夹的类型数值为
#define S_IFDIR 0040000
在git-submodule脚本中,可以看到很多直接使用160000来判断文件是否是外部link的逻辑。例如
repo=$1
sm_path=$2

if test -z "$sm_path"; then
sm_path=$(printf '%s\n' "$repo" |
sed -e 's|/$||' -e 's|:*/*\.git$||' -e 's|.*[/:]||g')
fi
……
if test -z "$force"
then
git ls-files --error-unmatch "$sm_path" > /dev/null 2>&1 &&
die "$(eval_gettext "'\$sm_path' already exists in the index")"
else
git ls-files -s "$sm_path" | sane_grep -v "^160000" > /dev/null 2>&1 &&
die "$(eval_gettext "'\$sm_path' already exists in the index and is not a submodule")"
fi

2、git对于mode的特殊处理

我们向git添加文件的时候,其实有一个没有明说的规则:git add添加的都是“文件”,而不包括文件夹。尽管在执行git add的时候经常使用文件夹来添加,但是git内部会将这个文件夹遍历,匹配特定文件(而不包括文件夹)。git有一个很著名的限制(https://stackoverflow.com/questions/115983/how-can-i-add-a-blank-directory-to-a-git-repository):不能添加空文件夹,原因就在于空文件中没有文件。
举个栗子:
tsecer@harry: tree
.
├── 1
└── tsecer
└── harry

1 directory, 2 files
tsecer@harry: git add tsecer
tsecer@harry: git ls-files -s
100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0 tsecer/harry
这里看到,虽然添加的是文件夹tsecer,但是在目录中只是添加了tsecer/harry文件,而没有添加tsecer。
如果创建一个空文件夹,然后添加,会发现添加是不成功的(没有生效)。
tsecer@harry: mkdir empty
tsecer@harry: git add empty
tsecer@harry: git ls-files -s
100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0 tsecer/harry
tsecer@harry:

3、git在目录遍历时如何处理文件夹

默认情况下,遍历的时候会首先判断文件夹是否是一个嵌套版本库(nested_repo),如果是的话根据配置决定是否递归该文件夹,并且默认是不再递归进入的。
这也就是说,git判断一个文件夹是否是nested_repo的时候,是根据目录本身的结构来判断,而不会根据.gitmodules、.git/config之类的外部配置文件判断。这种实现其实也比较容易理解,因为如果对于每一个文件都判断下.gitmodules之类的配置文件,效率会比较低。
* (b) if it looks like a git directory, and we don't have
* 'no_gitlinks' set we treat it as a gitlink, and show it
* as a directory.
* (c) otherwise, we recurse into it.
*/
static enum path_treatment treat_directory(struct dir_struct *dir,
struct index_state *istate,
struct untracked_cache_dir *untracked,
const char *dirname, int len, int baselen, int excluded,
const struct pathspec *pathspec)
{
……
if ((dir->flags & DIR_SKIP_NESTED_GIT) ||
!(dir->flags & DIR_NO_GITLINKS)) {
struct strbuf sb = STRBUF_INIT;
strbuf_addstr(&sb, dirname);
nested_repo = is_nonbare_repository_dir(&sb);
strbuf_release(&sb);
}
if (nested_repo)
return ((dir->flags & DIR_SKIP_NESTED_GIT) ? path_none :
(excluded ? path_excluded : path_untracked));
……
}

4、git如何判断一个文件夹是一个repo

从前面的代码可以看到,这个地方是通过is_nonbare_repository_dir函数完成,该函数会判断文件夹是否有一个.git文件或者文件夹。
如果是一个.git文件,那么文件中应该使用gitdir指明本地的、文件夹格式的.git目录,例如下面一个文件的内容
tsecer@harry: cat ~/.vim/bundle/YouCompleteMe/third_party/requests-futures/.git
gitdir: ../../.git/modules/third_party/requests-futures
而对于.git文件夹的判断,更多的是一个基本判断,例如判断"/objects"、"/refs"文件夹,包含HEAD文件等。
git-master\setup.c
int is_nonbare_repository_dir(struct strbuf *path)
{
int ret = 0;
int gitfile_error;
size_t orig_path_len = path->len;
assert(orig_path_len != 0);
strbuf_complete(path, '/');
strbuf_addstr(path, ".git");
if (read_gitfile_gently(path->buf, &gitfile_error) || is_git_directory(path->buf))
ret = 1;
if (gitfile_error == READ_GITFILE_ERR_OPEN_FAILED ||
gitfile_error == READ_GITFILE_ERR_READ_FAILED)
ret = 1;
strbuf_setlen(path, orig_path_len);
return ret;
}

四、为什么submodule要放在单独的.gitmodules文件

这里要说明一个基本的概念,我们通常使用最多的、保存本地配置的.git/config文件中的内容在clone、fetch的时候是不会同步给其它版本仓库的。它更多的是一个“本地仓库”的配置,其中还包括了作者名称和邮件地址,这些显然不可能同步给clone的版本库。

五、为什么submodule init和update要分开执行

这个和前面的结合可以知道,.gitmodules文件列出了该项目所有(可能)用到的外部库;而通常版本库的信息都是通过.git/config文件来描述的,例如版本库的远端(remote)地址,更新内容等。
init命令则可以根据.gitmodules和命令行参数决定哪些子模块的内容会被拷贝到.git/config文件中。这里要补充说明下,init是可以指定版本库列表的,也就是不是所有的.gitmodules文件夹中的内容都一定会被更新,可以选择只更新指定的若干个。这一点git-submodule命令的man手册中有详细描述:
init
Initialize the submodules recorded in the index (which were added and committed elsewhere) by copying submodule names and urls from .gitmodules to .git/config. Optional <path>
arguments limit which submodules will be initialized. It will also copy the value of submodule.$name.update into .git/config. The key used in .git/config is submodule.$name.url.
This command does not alter existing information in .git/config. You can then customize the submodule clone URLs in .git/config for your local setup and proceed to git submodule
update; you can also just use git submodule update --init without the explicit init step if you do not intend to customize any submodule locations.
对应的,update命令不再处理.gitmodules文件,而是只根据本地的.git/config文件配置来决定如何更新哪些版本库。

六、子库的oid是什么

1、将子库添加到cache时

可以看到,对于文件夹类型(S_IFDIR),会对文件夹中包含的.git路径进行解析,并把git库HEAD指定的commitid作为文件夹的oid。也就是说,如果一个文件夹是一个subrepo,那么这个文件夹在git内部tree结构中存储的是该repo HEAD的commitid。
git-master\sha1-file.c
int index_path(struct index_state *istate, struct object_id *oid,
const char *path, struct stat *st, unsigned flags)
{
int fd;
struct strbuf sb = STRBUF_INIT;
int rc = 0;

switch (st->st_mode & S_IFMT) {
case S_IFREG:
fd = open(path, O_RDONLY);
if (fd < 0)
return error_errno("open(\"%s\")", path);
if (index_fd(istate, oid, fd, st, OBJ_BLOB, path, flags) < 0)
return error(_("%s: failed to insert into database"),
path);
break;
case S_IFLNK:
if (strbuf_readlink(&sb, path, st->st_size))
return error_errno("readlink(\"%s\")", path);
if (!(flags & HASH_WRITE_OBJECT))
hash_object_file(the_hash_algo, sb.buf, sb.len,
blob_type, oid);
else if (write_object_file(sb.buf, sb.len, blob_type, oid))
rc = error(_("%s: failed to insert into database"), path);
strbuf_release(&sb);
break;
case S_IFDIR:
return resolve_gitlink_ref(path, "HEAD", oid);
default:
return error(_("%s: unsupported file type"), path);
}
return rc;
}

2、在执行ls-tree命令时显示的内容

从这里看到,在tree结构文件中,并没有存储每个条目是不是commit类型,这个类型是是将mode的特殊字段转换为更容易理解的字符串形式。
const char *commit_type = "commit";
static int show_tree(const struct object_id *oid, struct strbuf *base,
const char *pathname, unsigned mode, int stage, void *context)
{
int retval = 0;
int baselen;
const char *type = blob_type;

if (S_ISGITLINK(mode)) {
/*
* Maybe we want to have some recursive version here?
*
* Something similar to this incomplete example:
*
if (show_subprojects(base, baselen, pathname))
retval = READ_TREE_RECURSIVE;
*
*/
type = commit_type;
} else if (S_ISDIR(mode)) {
if (show_recursive(base->buf, base->len, pathname)) {
retval = READ_TREE_RECURSIVE;
if (!(ls_options & LS_SHOW_TREES))
return retval;
}
type = tree_type;
}
……
}

3、git status显示的submodule状态

刚刚clone出来的本地工作目录,没有执行submodule init/update之前,通过git status看是看不到需要更新外部模块的。或者说,在status命令的输出中,是否使用了submodule的信息是“隐身”的。
这一点从git的注释可以看到,如果一个submodule文件夹为空,则认为这个文件夹状态是匹配的(尽管这个文件夹还是空的)。
反过来说,这个文件夹如果包含了.git信息,那么就要求git的HEAD和版本库(commit中tree结构)内部该link对应的oid相同,否则认为该文件夹发生了变化。
git-master\read-cache.c
static int ce_compare_gitlink(const struct cache_entry *ce)
{
struct object_id oid;

/*
* We don't actually require that the .git directory
* under GITLINK directory be a valid git directory. It
* might even be missing (in case nobody populated that
* sub-project).
*
* If so, we consider it always to match.
*/
if (resolve_gitlink_ref(ce->name, "HEAD", &oid) < 0)
return 0;
return !oideq(&oid, &ce->oid);
}

static int ce_modified_check_fs(struct index_state *istate,
const struct cache_entry *ce,
struct stat *st)
{
switch (st->st_mode & S_IFMT) {
case S_IFREG:
if (ce_compare_data(istate, ce, st))
return DATA_CHANGED;
break;
case S_IFLNK:
if (ce_compare_link(ce, xsize_t(st->st_size)))
return DATA_CHANGED;
break;
case S_IFDIR:
if (S_ISGITLINK(ce->ce_mode))
return ce_compare_gitlink(ce) ? DATA_CHANGED : 0;
/* else fallthrough */
default:
return TYPE_CHANGED;
}
return 0;
}

七、submodule status的实现

1、如何知道所有的submodule

这个比较有意思的是,它同样没有使用.gitsubmodules,而是从clone过来的版本树中计算,因为git tree结构条目的mode存储了它是一个gitlnk的标志位,并且oid存储了这个版本库对应的commit信息。所以直接遍历tree结构的所以条目,就可以找到当前版本库中有哪些submodule。
git-master\builtin\submodule--helper.c
static int module_list_compute(int argc, const char **argv,
const char *prefix,
struct pathspec *pathspec,
struct module_list *list)
{
int i, result = 0;
char *ps_matched = NULL;
parse_pathspec(pathspec, 0,
PATHSPEC_PREFER_FULL,
prefix, argv);

if (pathspec->nr)
ps_matched = xcalloc(pathspec->nr, 1);

if (read_cache() < 0)
die(_("index file corrupt"));

for (i = 0; i < active_nr; i++) {
const struct cache_entry *ce = active_cache[i];

if (!match_pathspec(&the_index, pathspec, ce->name, ce_namelen(ce),
0, ps_matched, 1) ||
!S_ISGITLINK(ce->ce_mode))
continue;

ALLOC_GROW(list->entries, list->nr + 1, list->alloc);
list->entries[list->nr++] = ce;
while (i + 1 < active_nr &&
!strcmp(ce->name, active_cache[i + 1]->name))
/*
* Skip entries with the same name in different stages
* to make sure an entry is returned only once.
*/
i++;
}
……
}

2、如何判断子模块状态

可以看到,如果指定的文件夹不包含.git相关的版本信息,则认为本地目录有缺失(使用‘-’引导状态);否则在子文件夹执行git diff-files命令判断是否有更新;如果有更新则使用‘+引导,否则没有任何引导字符。
git-master\builtin\submodule--helper.c
unsigned is_submodule_modified(const char *path, int ignore_untracked)
{
……
strbuf_addf(&buf, "%s/.git", path);
git_dir = read_gitfile(buf.buf);
if (!git_dir)
git_dir = buf.buf;
if (!is_git_directory(git_dir)) {
if (is_directory(git_dir))
die(_("'%s' not recognized as a git repository"), git_dir);
strbuf_release(&buf);
/* The submodule is not checked out, so it is not modified */
return 0;
}
……
argv_array_pushl(&cp.args, "status", "--porcelain=2", NULL);
……
if (!diff_result_code(&rev.diffopt, diff_files_result)) {
print_status(flags, ' ', path, ce_oid,
displaypath);
} else if (!(flags & OPT_CACHED)) {
struct object_id oid;
struct ref_store *refs = get_submodule_ref_store(path);

if (!refs) {
print_status(flags, '-', path, ce_oid, displaypath);
goto cleanup;
}
if (refs_head_ref(refs, handle_submodule_head_ref, &oid))
die(_("could not resolve HEAD ref inside the "
"submodule '%s'"), path);

print_status(flags, '+', path, &oid, displaypath);
} else {
print_status(flags, '+', path, ce_oid, displaypath);
}
……
}

八、cache和tree的区别及转换

这里再补充说明下:cache和tree的结构是完全不同的。cache中只包含“非文件夹”条目,如果是一个文件夹,那么它一定是一个外部库引用。
而tree结构中当然是包含文件夹的(也就是tree结构),并且这个文件夹的信息是通过cache中(所有子文件夹)的条目计算。
所以,在commit的tree结构中,submodule类型文件夹(tree)结构的mode为0160000,而文件夹的mode为004000(这里只考虑类型信息而考虑其他读写权限信息),尽管他们在git中都是tree类型文件。
#define S_IFGITLINK 0160000
#define S_IFDIR 0040000
从cache到磁盘中tree结构的转换代码
static int update_one(struct cache_tree *it,
struct cache_entry **cache,
int entries,
const char *base,
int baselen,
int *skip_count,
int flags)
{
……
/*
* Find the subtrees and update them.
*/
i = 0;
while (i < entries) {
……
/*
* a/bbb/c (base = a/, slash = /c)
* ==>
* path+baselen = bbb/c, sublen = 3
*/
sublen = slash - (path + baselen);
sub = find_subtree(it, path + baselen, sublen, 1);
if (!sub->cache_tree)
sub->cache_tree = cache_tree();
subcnt = update_one(sub->cache_tree,
cache + i, entries - i,
path,
baselen + sublen + 1,
&subskip,
flags);
if (subcnt < 0)
return subcnt;
if (!subcnt)
die("index cache-tree records empty sub-tree");
i += subcnt;
sub->count = subcnt; /* to be used in the next loop */
*skip_count += subskip;
sub->used = 1;
}
……

/*
* Then write out the tree object for this level.
*/
strbuf_init(&buffer, 8192);

i = 0;
while (i < entries) {
const struct cache_entry *ce = cache[i];
……
/*
* "sub" can be an empty tree if all subentries are i-t-a.
*/
if (contains_ita && is_empty_tree_oid(oid))
continue;

strbuf_grow(&buffer, entlen + 100);
strbuf_addf(&buffer, "%o %.*s%c", mode, entlen, path + baselen, '\0');
strbuf_add(&buffer, oid->hash, the_hash_algo->rawsz);

#if DEBUG_CACHE_TREE
fprintf(stderr, "cache-tree update-one %o %.*s\n",
mode, entlen, path + baselen);
#endif
}

if (repair) {
struct object_id oid;
hash_object_file(the_hash_algo, buffer.buf, buffer.len,
tree_type, &oid);
if (has_object_file_with_flags(&oid, OBJECT_INFO_SKIP_FETCH_OBJECT))
oidcpy(&it->oid, &oid);
else
to_invalidate = 1;
} else if (dryrun) {
hash_object_file(the_hash_algo, buffer.buf, buffer.len,
tree_type, &it->oid);
} else if (write_object_file(buffer.buf, buffer.len, tree_type,
&it->oid)) {
strbuf_release(&buffer);
return -1;
}

strbuf_release(&buffer);
it->entry_count = to_invalidate ? -1 : i - *skip_count;
#if DEBUG_CACHE_TREE
fprintf(stderr, "cache-tree update-one (%d ent, %d subtree) %s\n",
it->entry_count, it->subtree_nr,
oid_to_hex(&it->oid));
#endif
return i;
}

九、举个栗子

以github上git的版本库https://github.com/git/git.git为例。

1、新clone下来的版本库内容

可以看到,git status显示本地没有任何修改;submodule status显示文件远端版本库在sha1collisiondetection文件夹使用的commitid。
tsecer@harry: git status
On branch master
Your branch is up to date with 'origin/master'.

nothing to commit, working tree clean
tsecer@harry: git submodule status
-855827c583bc30645ba427885caa40c5b81764d2 sha1collisiondetection
tsecer@harry: cat .git/config
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
[remote "origin"]
url = https://github.com/git/git.git
fetch = +refs/heads/*:refs/remotes/origin/*
[branch "master"]
remote = origin
merge = refs/heads/master
tsecer@harry: cat .gitmodules
[submodule "sha1collisiondetection"]
path = sha1collisiondetection
url = https://github.com/cr-marcstevens/sha1collisiondetection.git
branch = master
tsecer@harry:

2、init并update

执行init,主要就是在.git/config文件夹中添加配置
[submodule "sha1collisiondetection"]
active = true
url = https://github.com/cr-marcstevens/sha1collisiondetection.git

tsecer@harry: git submodule init
Submodule 'sha1collisiondetection' (https://github.com/cr-marcstevens/sha1collisiondetection.git) registered for path 'sha1collisiondetection'
tsecer@harry: git submodule update
Cloning into '/home/tsecer/git/sha1collisiondetection'...
Submodule path 'sha1collisiondetection': checked out '855827c583bc30645ba427885caa40c5b81764d2'
tsecer@harry: cat .git/config
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
[remote "origin"]
url = https://github.com/git/git.git
fetch = +refs/heads/*:refs/remotes/origin/*
[branch "master"]
remote = origin
merge = refs/heads/master
[submodule "sha1collisiondetection"]
active = true
url = https://github.com/cr-marcstevens/sha1collisiondetection.git
tsecer@harry:

3、在sha1collisiondetection文件夹修改

由于status_submodule函数在执行"diff-files"的时候添加了 "--ignore-submodules=dirty"选项,忽略了所有工作目录的修改。
--ignore-submodules[=<when>]
Ignore changes to submodules in the diff generation. <when> can be either "none", "untracked", "dirty" or "all", which is the default. Using "none" will consider the submodule
modified when it either contains untracked or modified files or its HEAD differs from the commit recorded in the superproject and can be used to override any settings of the
ignore option in git-config(1) or gitmodules(5). When "untracked" is used submodules are not considered dirty when they only contain untracked content (but they are still
scanned for modified content). Using "dirty" ignores all changes to the work tree of submodules, only changes to the commits stored in the superproject are shown (this was the
behavior until 1.7.0). Using "all" hides all changes to submodules.

tsecer@harry: git submodule status
855827c583bc30645ba427885caa40c5b81764d2 sha1collisiondetection (stable-v1.0.3-34-g855827c)
tsecer@harry: 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 restore <file>..." to discard changes in working directory)
(commit or discard the untracked or modified content in submodules)
modified: sha1collisiondetection (modified content)

no changes added to commit (use "git add" and/or "git commit -a")
tsecer@harry:

4、将修改进行commit

两个命令都显示了状态有修改。并且submodule中显示的状态前有一个‘+’,提示根目录(git repo)中保存的内容和sha1collisiondetection文件夹最新的commit(HEAD)不同。
tsecer@harry: 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 restore <file>..." to discard changes in working directory)
modified: sha1collisiondetection (new commits)

no changes added to commit (use "git add" and/or "git commit -a")
tsecer@harry: git submodule status
+92c5773d86a4f5a4499854187e2aae204b7ef1be sha1collisiondetection (stable-v1.0.3-35-g92c5773)
tsecer@harry: cat sha1collisiondetection/.git
gitdir: ../.git/modules/sha1collisiondetection
tsecer@harry: cat .git/modules/sha1collisiondetection/HEAD
92c5773d86a4f5a4499854187e2aae204b7ef1be
tsecer@harry:

十、一个扩展:如何让submodule使用特定commit

从前面的讨论可以看到,submodule具体引用那个commit是在父项目commit的tree内部保存的commitid,没有办法通过配置文件修改。
所以需要先按照clone过来的内容update,之后再在submodule的repo中执行特定版本的checkout。
如果想让这个checkout生效,把submodule文件夹执行git add,然后进行commit。因为只有这样才可以修改commit内tree结构的oid(文件hash值)。

 

posted on 2022-02-28 20:53  tsecer  阅读(769)  评论(0编辑  收藏  举报

导航