Git submodules as a package manager
- title
- Git submodules as a package manager
- type
- summary
- summary
- Nesbitt reads submodules as a package manager - gitlink as lockfile, .gitmodules as manifest - and finds resolution, storage and updates each worse
- tags
- git, package-management, dependencies, security
- created
- 2026-09-13
- updated
- 2026-09-13
Andrew Nesbitt got there through a small annoyance. He added a worktree, ran git submodule update --init in it, and then git worktree remove refused. The man page gives submodules their own clause next to dirty state: removing such a worktree needs --force, and git worktree move won't move one at all. The caveat has been there since git worktree arrived in git 2.5 in July 2015, and worktree add later had to be patched to ignore submodule.recurse because honouring it made its internal reset --hard recurse into empty submodule paths.
That led him to read submodules as a package manager. Most of the parts exist. A gitlink, a commit SHA recorded at a path with mode 160000, is the lockfile entry. `.gitmodules`, mapping paths to URLs, is the manifest. git submodule update is the install step. The pin is as exact as any lockfile's. Almost every other step is worse than in a real package manager.
Resolution
A gitlink says which commit to check out, and the URL in .gitmodules says where to get it. There is no other resolution mechanism. If the upstream is renamed, moved to another host or made private, every pin to it breaks, even though the SHA hasn't changed and the objects exist in every clone that already has them.
On top of that, git submodule init copies each URL into .git/config, and later commands read it from there. Changing .gitmodules to point at a mirror does nothing for an existing clone until git submodule sync. In CI the usual workaround is a global url.<base>.insteadOf rewrite, for example GitHub HTTPS to SSH so a deploy key applies.
This is where cursed-bundler-go-get-ruby-gems makes a useful contrast: Golang's module proxy is exactly the lookup from a content identity to servers holding it that submodules lack.
Installation
A plain clone records the gitlink and leaves the directory empty until git submodule update --init or --recurse-submodules. The submodule.recurse setting, which makes checkout, fetch, pull and grep recurse, defaults to off. update checks out the pinned commit detached; --remote checks out the tip of the tracked branch instead, so one command name covers both "install what's pinned" and "upgrade to latest".
Switching superproject branches updates the gitlink but leaves the submodule's working tree behind, and git status immediately shows it as modified. The Rust project's June 2026 write-up on moving compiler subprojects off submodules lists the resulting problems from experience: empty or wrong checkouts after clone, unrelated submodule bumps slipping into pull requests after a branch switch, and custom logic in bootstrap to put each submodule on the right commit before building.
Storage
A submodule's git directory lives under $GIT_DIR/modules/<name>/, with its own refs, index, config, hooks and by default its own object store; the working tree has a .git file pointing back. Removal takes three steps: git rm, git submodule deinit, and a documented manual rm -rf of the leftover modules directory.
This layout is why worktrees and submodules collide. Linked worktrees share $GIT_DIR but have their own HEAD and index, so two worktrees on different branches reference the same submodule at different commits, over storage that is partly shared and partly per-worktree. Git asks for --force rather than working out whether that state is disposable, and move refuses because the pointer rewrite is unimplemented. After Xavier Morel asked on the git list in March 2026 whether a submodule checkout could be a worktree of a shared clone, an RFC and a three-patch series followed in April proposing git worktree add --recurse-submodules, with per-worktree submodule directories under $GIT_COMMON_DIR/worktrees/<id>/modules/ sharing objects by hardlink.
The same duplication happens without worktrees. Two submodules that depend on one repository get two module directories, two object stores unless alternates are set up by hand, and two independent pins. Cargo's registry cache, pnpm's content-addressable store and the Golang module cache store the bytes once.
Updating
Moving a pin means entering the submodule, fetching, checking out, leaving, and git add on the path; update --remote shortcuts the middle. The manifest can name a branch, but has no version ranges, tag patterns or minimum commits, so a branch is the only floating reference. Dependabot's gitsubmodule ecosystem and Renovate's git-submodules manager (disabled by default) both follow branch tips for that reason. The range semantics other package managers argue about, covered in default-version-bound-constraints, don't exist here at all.
Security
.gitmodules is controlled by the upstream and parsed during clone --recurse-submodules before the user has seen anything, and that has produced remote code execution repeatedly. CVE-2018-11235 used ../ in a submodule name to write its git directory, hooks included, outside modules/. CVE-2018-17456 used a URL starting with -, which the child clone parsed as an option. CVE-2022-39253 used a symlinked object directory to copy local files during a local-transport clone, and the fix made protocol.file.allow default to user. CVE-2024-32002 combined a symlink with a case-insensitive filesystem to write a hook into .git/. The general pattern of checkout paths as attack surface is part of supply-chain-security.
Abstraction
Submodules expose git internals directly: object IDs as pins, detached HEADs, the modules layout, transport URLs in the manifest. A package manager puts a manifest format, resolver and cache in front of the equivalent pieces. Nesbitt's list of what's missing is mostly already solved elsewhere: a shared object cache, recursion by default, one lifecycle for adding and removing, and range constraints. The April patch series addresses one storage case. He thinks resolution is the hard one: a SHA is already a host-independent identity for the content, and a URL in .gitmodules is still the only way git has to find a server that holds it. dependency-vendoring is the other way out, where the dependency's bytes live in the superproject and the question never comes up.