logo
Kurashizu Blogwhere ideas flow
  • Home
  • News
  • Blog
  • About
  • Home
  • News
  • Blog
  • About

© 2026 Kurashizu. All rights reserved.

Admin·New Post·Service Status
Powered by Cloudflare·31.10.26
← Back to news

Git Submodules as a Package Manager

Original article
nesbitt.io|79 points|26 comments|by ErenayDev|Sep 1, 2026

Git Submodules as a Package Manager

By Andrew Nesbitt

Recently, I attempted to use a git worktree to test a branch alongside my primary checkout. After running git submodule update --init to satisfy build dependencies, I tried to clean up using git worktree remove. To my surprise, Git blocked the operation.

According to the documentation, only "clean" worktrees can be deleted. Those that are "unclean" or contain submodules require the --force flag.

"It’s not recommended to use git worktree with a repository that contains submodules." — GitHub, July 2015

It is telling that submodules are singled out as a distinct category from "dirty" states. Furthermore, git worktree move is even more rigid, refusing to operate on any worktree with submodules entirely. Despite a decade passing since the initial warning, these two features still collide. Even worktree add required a patch to ignore submodule.recurse because the internal reset --hard would attempt to recurse into directories that were still empty.

Git submodules are a seamless experience. In reality, so many developers have adopted and then abandoned them that the question "Why are git submodules so bad?" has become a staple of developer forums.


The "Package Manager" Mental Model

If you view Git submodules through the lens of a package manager, the architecture becomes clearer:

Package Manager ConceptGit Submodule EquivalentDescription
Manifest.gitmodulesMaps the submodule path to its fetch URL.
Lockfile EntryGitlinkA commit SHA recorded at a path with mode 160000\text{160000}160000.
Install Stepgit submodule updateReads the manifest and lockfile to populate the tree.

Resolution and Pinning

The "pin" is incredibly precise: it is an exact commit identified by an object ID. However, the resolution mechanism is fragile. Because Git cannot look up which server holds a specific commit ID, the .gitmodules file must hard-code a URL.

The Risk: If the upstream repository is deleted, renamed, or made private, the pin breaks—even if the objects still exist in your local clone.

Configuration Nuances

  1. Caching: When git submodule init first runs, Git copies the URL into .git/config under submodule.<name>.url. Subsequent commands use this cached value, ignoring .gitmodules.
  2. Updating: To reflect changes made to .gitmodules (like switching to a mirror), you must run git submodule sync.
  3. Rewriting: You can use base .insteadOf in your global config to rewrite URLs on the fly.
    • Example: Rewriting https://github.com/ →\rightarrow→ git@github.com: to ensure SSH deploy keys are used.

Installation and Workflow

A standard git clone creates the submodule directory but leaves it empty. To actually fetch the content, you must use:

  • git submodule update --init
  • Or clone with the --recurse-submodules flag.

The update Command

The update command is confusingly named because it handles both installation and upgrading:

  • --init: Copies entries from .gitmodules to .git/config (required for the first run).
  • --remote: Instead of the pinned gitlink commit, it checks out the tip of the remote-tracking branch.

The Branch-Switching Trap

If you switch branches in the superproject, the gitlink in the index changes, but the submodule's working tree stays put. This results in git status reporting the submodule as "modified" immediately. To avoid this, you must set submodule.recurse to true or pass --recurse-submodules to the checkout command.

Case Study: The Rust Project

The Rust compiler team moved away from submodules due to several pain points:

  • Checkouts being left empty or on incorrect commits after cloning.
  • Accidental submodule updates appearing in PRs because a branch switch left the submodule "dirty."
  • The need for custom bootstrap build logic to force submodules to the correct commits.

Storage and Deletion

Internal Layout

Submodule data is stored in the superproject's directory: $GIT_DIR/modules/<name>/

The submodule's working directory contains a .git file (not a folder) that acts as a pointer: gitdir: ../.git/modules/<name>

Note: git submodule absorbgitdirs is used to migrate older clones that still use nested .git/ directories.

The Removal Process

Deleting a submodule is a fragmented process that requires three distinct steps:

  • git rm <path> →\rightarrow→ Removes the gitlink and the .gitmodules entry.
  • git submodule deinit <path> →\rightarrow→ Clears the working tree and .git/config entry.
  • rm -rf $GIT_DIR/modules/<name> →\rightarrow→ Manually deletes the remaining internal storage.

Why Worktrees Collide

The conflict arises because a linked worktree shares the superproject's $GIT_DIR but maintains its own HEAD and index. If two worktrees are on different branches, they may require the same submodule to be at two different commits simultaneously. Since the storage is partially shared and partially per-worktree, the logic breaks down, necessitating the --force flag for removal.