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.
... continue reading