# Git submodules as a package manager

[[nesbitt-io|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. [[git-magic-files|`.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.
