将 Git Submodules 用作包管理器
Git Submodules as a Package Manager

原始链接: https://nesbitt.io/2026/09/01/git-submodules-as-a-package-manager.html

Git 子模块(submodules)的功能类似于一个原始的手动包管理器,但其在易用性和架构设计上存在重大缺陷。尽管它们通过提交哈希(commit SHA)提供了精确的版本锁定,但该系统缺乏现代包管理器所具备的强大抽象能力、自动解析功能和缓存共享机制。 一个主要的痛点是子模块与 `git worktree` 等其他 Git 功能之间的冲突。由于子模块在父项目中维护着各自独立的仓库结构,它们经常与工作树(worktree)操作产生冲突,导致在移除时往往需要使用 `--force` 参数,甚至完全无法移动。 此外,子模块还存在安全漏洞和操作风险。由于 Git 在递归克隆期间会解析 `.gitmodules` 文件(通常在用户验证内容之前),该功能历史上曾成为远程代码执行的攻击面。维护工作同样繁重:更新、移除或重定向依赖项,都需要手动干预多个配置文件和隐藏的存储目录。 归根结底,子模块暴露了 Git 的内部存储实现,而没有为依赖管理提供一个简洁的接口。虽然近期的补丁尝试缓解工作树中的存储冲突问题,但其底层设计——依赖原始 URL 和手动指针更新——依然是开发者复杂度和挫败感的持续来源。

Hacker News 最新 | 往期 | 评论 | 提问 | 展示 | 招聘 | 提交 登录 Git Submodules 作为包管理器 (nesbitt.io) 7 分,ErenayDev 发布于 1 小时前 | 隐藏 | 往期 | 收藏 | 讨论 | 帮助 准则 | 常见问题 | 列表 | API | 安全 | 法律 | 申请 YC | 联系 搜索:
相关文章

原文

I added a worktree to a repository last week to try a branch alongside the main checkout, ran git submodule update --init in it because the build needed the vendored dependencies, and when I was done went to clean up with git worktree remove, which git refused. Per the man page only clean worktrees can be removed, and “unclean worktrees or ones with submodules” need --force. Submodules get their own clause in that sentence, distinct from dirty state. git worktree move is stricter again and refuses outright on any worktree containing submodules. I’d spent the previous week cataloguing how command-line tools harden their --force flags, and here was git requiring one because two of its own features had collided.

GitHub’s git 2.5 announcement introduced git worktree in July 2015 with a one-line caveat: “It’s not recommended to use git worktree with a repository that contains submodules.” Eleven years later git still requires --force to remove a worktree that has submodules and refuses to move one. In between, worktree add had to be patched to ignore submodule.recurse because honouring it made the internal reset --hard recurse into submodule paths that were still empty in the fresh worktree.

This got me thinking about submodules as a package manager. Most of the pieces are there and the behaviour roughly matches, but they don’t quite line up and the experience of using them is worse at almost every step. Enough projects have adopted them and then backed out that “why are git submodules so bad” is a recurring thread.

The gitlink in the superproject’s tree, a commit SHA recorded at a path with mode 160000, is the lockfile entry, and the .gitmodules file mapping paths to fetch URLs is the manifest. git submodule update reads both and populates the working tree, which is the install step. The pin itself is as precise as any package manager’s: an exact commit identified by object ID.

Resolution

The gitlink records only which commit to check out, so .gitmodules carries a url per submodule and update clones from there, which is the only resolution mechanism. If the upstream repository is renamed, transferred to a different host, or taken private, every downstream pin breaks, even though the SHA is unchanged and the objects still exist in every clone that already has them. The manifest hard-codes a host because git has no lookup from a commit ID to servers that hold it.

Git also copies each URL into the superproject’s .git/config the first time git submodule init runs, under submodule.<name>.url, and later commands read it from there, ignoring .gitmodules. Editing the committed .gitmodules to point at a mirror or a fork leaves an already-initialised clone unchanged until git submodule sync copies the new value across.

The usual workaround in CI is git’s global url.<base>.insteadOf config, which rewrites any URL with a matching prefix before fetching, submodule URLs included. The common cases are rewriting https://github.com/ to [email protected]: so an SSH deploy key applies, or redirecting an internal hostname to a mirror.

Installation

A plain git clone writes the gitlink into the index so the submodule directory exists, and leaves it empty until git submodule update --init runs or the clone was made with --recurse-submodules. The submodule.recurse config setting makes checkout, fetch, pull, grep and several other commands recurse automatically, and it defaults to off.

By default update checks out the gitlink commit, detached, and two independent flags modify that:

  • --init: copy any missing .gitmodules entries into .git/config first, required on first run and a no-op after
  • --remote: check out the tip of the submodule’s configured remote-tracking branch instead of the gitlink commit (the remote’s HEAD if submodule.<name>.branch is unset)

The command reference documents both, though the name update conflates “install what’s pinned” with “update to latest”. Switching branches in the superproject changes the gitlink in the index and leaves the submodule’s working tree wherever it already was, so git status immediately shows the submodule as modified. Passing --recurse-submodules to checkout, or setting submodule.recurse, brings the submodule working tree along with the branch switch. The Rust project’s account of moving compiler subprojects off submodules lists this cluster from experience: checkouts left empty or on the wrong commit after clone, unrelated submodule bumps landing in pull requests because a branch switch left the submodule dirty, and custom logic in the bootstrap build tool to check each submodule out to the right commit before building.

Storage

A submodule’s git directory is stored under the superproject’s $GIT_DIR/modules/<name>/, with a .git file in the submodule’s working tree containing a gitdir: pointer back to it and a core.worktree setting pointing the other way. git submodule absorbgitdirs migrates older clones that still have a nested .git/ directory. Each entry under modules/ is a git directory with its own refs, HEAD, index, config, hooks, and by default its own object store. Removing a submodule is correspondingly spread across three places: git rm <path> drops the gitlink and the .gitmodules entry, git submodule deinit <path> clears the working tree and the .git/config entry, and the absorbed $GIT_DIR/modules/<name> directory that both leave behind is documented as a manual rm -rf.

Worktrees and submodules collide over this layout because a linked worktree shares the superproject’s $GIT_DIR but has its own working tree, HEAD, and index under $GIT_DIR/worktrees/<id>/. Put two worktrees on different superproject branches and they reference the same submodule at two different commits. Each needs its own submodule checkout and index, tied to storage that’s partly per-worktree and partly shared. worktree remove requires the override rather than checking whether that state is disposable, and worktree move refuses because the pointer-file rewrite it would need is unimplemented.

Xavier Morel asked on the git list this March whether a submodule checkout could itself be a worktree of an existing shared clone, having found bare repositories plus worktrees worked well for a set of related projects but that adding submodules on top always cloned fresh. An RFC and a three-patch series proposing --recurse-submodules for git worktree add followed in April, giving each linked worktree its own submodule git directory under $GIT_COMMON_DIR/worktrees/<id>/modules/ and sharing the object storage between them by hardlink.

The same multiplication happens in a single-worktree clone when two submodules both depend on a third repository. Each path in the superproject gets its own modules/ entry, its own object store unless alternates are configured by hand, and its own gitlink. The two pins can point at different commits of the same repository, and git treats them as unrelated checkouts. Package managers with a shared cache (cargo’s registry cache, pnpm’s content-addressable store, the Go module cache) store the bytes once and check them out per location.

Updating

The gitlink holds one commit SHA, so moving a submodule forward means entering it, fetching, checking out the new commit, leaving, and git add <path> in the superproject to record the new gitlink. git submodule update --remote fetches the configured branch’s tip and checks that out instead of the recorded gitlink, and committing the result in the superproject is what moves the pin. .gitmodules can name a branch per submodule for --remote and the update bots to follow. A plain update ignores that field and checks out the gitlink SHA regardless. There is no syntax for a version range, a tag pattern, or a minimum commit, so the manifest’s only floating reference is a branch name and the gitlink is the only pin.

Dependabot and Renovate can both open pull requests bumping a gitlink. Dependabot’s gitsubmodule ecosystem proposes a new gitlink SHA when the submodule’s configured branch moves, and Renovate’s git-submodules manager does the same, shipping disabled by default; both follow branch tips because a branch name is the only reference the manifest exposes.

Security

.gitmodules is committed to the repository, so a hostile upstream controls its contents, and git parses it during clone --recurse-submodules before the user has seen any of the fetched files, a combination that has produced remote code execution repeatedly. CVE-2018-11235 used ../ in a submodule’s name so its git directory, hooks included, was written outside $GIT_DIR/modules/ and a post-checkout hook ran during clone. In CVE-2018-17456 the submodule URL began with -, so the child git clone parsed it as an option, the class of bug git’s --end-of-options delimiter defends against. CVE-2022-39253 was a disclosure bug: a symlink in a submodule’s object directory made a local-transport clone copy arbitrary files from the victim’s disk. The fix changed the protocol.file.allow default to user, so local-path submodules now need an explicit opt-in. CVE-2024-32002 combined a symlink with a case-insensitive filesystem to write a hook into .git/ during recursive clone. I covered the broader pattern of package-manager checkout paths as an attack surface in the CWE field guide.

Abstraction

Submodules expose git’s internals directly: object IDs as the pin, detached HEADs after update, the $GIT_DIR/modules/ layout, transport URLs in the manifest. A package manager wraps the equivalents behind a manifest format, a resolver, and a local cache; submodules surface them raw.

Most of the gaps map to things package managers already solved: a shared object cache, recursing into dependencies by default on clone and checkout, a single lifecycle for adding and removing a dependency, range constraints in the manifest. The April patch series adding --recurse-submodules to git worktree add tackles one instance of the storage problem, giving each worktree its own submodule checkout over hardlinked shared storage. Resolution is the harder one: a commit SHA is a host-independent identity for the object, and the URL in .gitmodules is git’s only mapping from that identity to a server that holds it.

联系我们 contact @ memedata.com