Git submodules turn out to be a package manager, and a bad one
The piece opens with a specific incident: the author added a worktree to a repository, ran git submodule update --init in it because the build needed vendored dependencies, then tried git worktree remove and was refused. Per the man page, only clean worktrees can be removed; "unclean worktrees or ones with submodules" need --force, with submodules called out as their own clause distinct from dirty state. git worktree move is stricter still and refuses outright on any worktree containing submodules. GitHub's git 2.5 release announcement introduced git worktree in July 2015 with a one-line caveat against combining it with submodules, and eleven years later that friction is unresolved: worktree add even had to be patched at one point to ignore submodule.recurse, because honoring it made the internal reset --hard recurse into submodule paths that were still empty in a fresh worktree.
From there the author builds the case that submodules already function as an ad hoc package manager. The gitlink, a commit SHA recorded in the superproject's tree at a path with mode 160000, is the lockfile entry; .gitmodules, which maps paths to fetch URLs, is the manifest; git submodule update reading both and populating the working tree is the install step. The pin itself is as precise as any package manager's, an exact commit by object ID.
Resolution is where it breaks down. .gitmodules hard-codes a URL per submodule because git has no lookup from a commit ID to a server that holds it, so if the upstream repository is renamed, moved to a different host, or made private, every downstream pin breaks even though the SHA and the objects are unchanged. Git also copies each URL into the superproject's .git/config under submodule.<name>.url the first time git submodule init runs, and later commands read from there, not from .gitmodules; editing the committed .gitmodules to point at a mirror leaves an already-initialized clone unchanged until git submodule sync copies the new value across. The common CI workaround is git's global url.<base>.insteadOf, typically rewriting https://github.com/ to an SSH form so a deploy key applies, or redirecting an internal hostname to a mirror.
On installation, submodule.recurse defaults to off, so a plain clone leaves submodule directories empty until git submodule update --init runs (or the clone used --recurse-submodules). --init copies missing .gitmodules entries into .git/config; --remote checks out the submodule's configured remote-tracking branch tip instead of the pinned commit. Switching branches in the superproject changes the gitlink but leaves the submodule's working tree wherever it was, so git status immediately flags it as modified; only passing --recurse-submodules to checkout, or setting submodule.recurse, brings the submodule tree along automatically. The author cites the Rust project's own account of moving compiler subprojects off submodules as evidence from real use: 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 added to the bootstrap build tool just to check each submodule out to the right commit before building.
On storage, each submodule's git directory lives under the superproject's $GIT_DIR/modules/<name>/, with its own refs, HEAD, index, config, hooks and, by default, its own object store; removing a submodule cleanly means three separate steps (git rm, git submodule deinit, and a manual rm -rf of the leftover modules directory). This collides with worktrees, which share the superproject's $GIT_DIR but keep their own working tree, HEAD and index: two worktrees on different superproject branches can reference the same submodule at two different commits, each needing its own checkout and index. That is why worktree remove demands the override rather than checking whether the state is disposable, and worktree move refuses outright, since the pointer-file rewrite it would need is unimplemented. The same multiplication happens even in a single worktree when two submodules both depend on a third repository, each getting its own object store unless alternates are configured by hand, unlike package managers with a shared cache such as cargo's registry cache, pnpm's content-addressable store or the Go module cache.
Xavier Morel raised the worktree/submodule conflict on the git mailing list in March, asking whether a submodule checkout could itself be a worktree of an existing shared clone, having found that bare repositories plus worktrees worked well for a set of related projects until submodules were added and 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/ while sharing the underlying object storage between them by hardlink; the article does not say whether that series has since been merged.
On updating, moving a submodule forward normally means entering it, fetching, checking out the new commit, and running git add <path> in the superproject to record the new gitlink; git submodule update --remote automates the fetch-and-checkout part. There is no syntax for a version range, a tag pattern or a minimum commit, so a branch name is the manifest's only floating reference and the gitlink is the only pin. Both Dependabot's gitsubmodule ecosystem and Renovate's git-submodules manager open pull requests bumping a gitlink when a submodule's configured branch moves; Renovate ships that feature disabled by default.
The security section lists four CVEs as evidence that raw URLs and paths in a committed manifest are a real attack surface, since .gitmodules is committed by the upstream and git parses it during clone --recurse-submodules before the user has seen any fetched files. 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. CVE-2018-17456 had a submodule URL start with a hyphen, so the child git clone parsed it as an option, the exact class of bug git's --end-of-options delimiter defends against. CVE-2022-39253 was a disclosure bug in which a symlink in a submodule's object directory made a local-transport clone copy arbitrary files off the victim's disk, fixed by changing 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 a recursive clone.
The author closes by arguing most of the gaps map to problems package managers already solved: a shared object cache, recursing into dependencies by default, a single add/remove lifecycle, and range constraints in the manifest. Resolution is called out as the harder, unsolved one: a commit SHA is a host-independent identity for an object, but the URL in .gitmodules remains git's only mapping from that identity to a server that holds it.
Key facts
- git worktree, introduced in git 2.5's July 2015 release, still requires --force to remove a worktree containing submodules eleven years later, and git worktree move refuses outright on one.
- Four CVEs are cited as evidence of the risk in a committed, git-parsed manifest: CVE-2018-11235 and CVE-2018-17456 (remote code execution via a submodule name and URL respectively), CVE-2022-39253 (arbitrary file disclosure via a symlink, fixed by defaulting protocol.file.allow to user), and CVE-2024-32002 (a symlink plus case-insensitive filesystem writing a hook into .git/).
- The gitlink (a commit SHA at a mode-160000 path) works as the lockfile and .gitmodules as the manifest, but there is no version-range syntax at all: a branch name is the only floating reference, and the gitlink is the only pin.
- Xavier Morel's March question on the git mailing list about sharing submodule checkouts across worktrees led to an April RFC and three-patch series adding --recurse-submodules to git worktree add, giving each worktree its own submodule git directory under $GIT_COMMON_DIR/worktrees/
/modules/ while sharing object storage by hardlink. - Dependabot's gitsubmodule ecosystem and Renovate's git-submodules manager (shipped disabled by default) both bump a gitlink automatically when a submodule's configured branch moves.
Why it matters
The article's core claim is that git submodules already provide most of what a package manager needs, an exact pin (the gitlink), a manifest (.gitmodules), and an install step (git submodule update), but expose git's internals raw instead of wrapping them: object IDs as the pin, detached HEADs after update, a filesystem layout under $GIT_DIR/modules/, and transport URLs baked into the manifest. That framing explains a recurring pattern the author names directly, that enough projects have adopted submodules and then backed out of them that "why are git submodules so bad" is a standing question, and traces it to specific, nameable design gaps rather than vague dissatisfaction: no shared object cache, no default recursion into dependencies, a split add/remove lifecycle spread across three commands, and no version-range syntax.
Who it affects
Anyone vendoring dependencies via git submodules, and especially teams that also use git worktrees, since the two features actively collide: two worktrees on different branches of the same superproject can pin the same submodule to two different commits, each requiring its own checkout, index and often its own object store. The Rust project is cited as a team that hit this in production while maintaining compiler subprojects, and CI maintainers who rely on submodule.
How to use it
There is no product to adopt here; the practical takeaways are operational. Treat a submodule's pinned URL as sticky: after editing .gitmodules to point at a mirror or fork, run git submodule sync or an already-initialized clone will keep using the old submodule.
How solid is it
The piece reads as a first-hand technical investigation: the author states they had spent the prior week cataloguing how command-line tools harden their --force flags before hitting this exact collision in git, and the analysis cites specific, checkable sources throughout, the git worktree man page, GitHub's original git 2.5 release announcement from July 2015, the Rust project's own postmortem on dropping submodules, and named CVE identifiers with their specific mechanisms. Dates given are relative to the time of writing ("this March", "in April") rather than tied to an absolute year, and the article does not say whether the discussed RFC and patch series for git worktree add has actually been merged into git, only that it followed the mailing list discussion.
Risks and caveats
The security history is real and specific, four named CVEs spanning 2018 to 2024, two of them remote code execution during clone and two involving file disclosure or hook injection via symlinks and case-insensitive filesystems, all stemming from the same structural issue: .gitmodules is attacker-controlled by a hostile or compromised upstream and git parses it before the user has reviewed any fetched content. The resolution problem the author flags as unsolved, that a commit SHA has no built-in mapping to a server that still holds it, means a renamed, transferred, or privatized upstream can silently break every downstream pin regardless of object integrity. The worktree and submodule interaction remains only partially fixed as described: the April patch series addresses the storage-multiplication problem but the harder resolution problem is explicitly called out as unaddressed by any of the changes discussed.
“It's not recommended to use git worktree with a repository that contains submodules.”
— GitHub's git 2.5 release announcement, July 2015