Pakkit.net
← Back to blog

Engineering Practice

Git Submodules Pin, They Don't Sync

Most submodule pain comes from one wrong mental model — treating a submodule like a live link to another repo's branch, when it's really a pin to one exact commit that only moves when you move it.

  • Engineering Practice
  • Git
  • Source Control
  • Tooling

Git submodules have a bad reputation, and most of it is undeserved. The pain people blame on submodules is almost always the result of one wrong mental model: expecting a submodule to track another repo, like a symlink that always points at the latest. It doesn’t. A submodule is a pin — a recorded “this superproject goes with exactly this commit of that child repo” — and it only moves when you explicitly move it. Once that clicks, the whole feature stops fighting you.

A submodule is a commit, not a branch

When a parent repo (the “superproject”) includes another repo as a submodule, it does not store “the develop branch of that repo.” It stores one specific commit hash. Cloning the parent and initializing submodules checks each child out at exactly that recorded hash — typically in a detached HEAD, because a raw commit isn’t a branch.

That detached state surprises people, but it’s honest: the parent pinned a commit, so that’s what you get. The submodule isn’t broken and it isn’t behind; it’s sitting precisely where the parent told it to sit. Updating the child repo’s branch on its own remote does nothing to the parent until someone goes into the parent and re-pins it to the newer commit. No magic, no sync. Just a pointer you control.

A submodule doesn’t follow a branch. It remembers a commit. Drift happens when you assume the first and the system is doing the second.

The two failure modes both come from that gap

Nearly every “ugh, submodules” moment is one of two situations, and both are the same root confusion wearing different hats:

  • The stale pin. The child repo’s work got merged to its mainline weeks ago, but the parent still points at an old feature commit because nobody bumped the pointer. The superproject looks current; it’s silently shipping old code. From the outside everything’s green, which is what makes it dangerous — the same shape as a shared name that hides drift.
  • The detached drift. Someone did work inside the submodule directory while it was in detached HEAD, committed it onto nothing, and now there are commits that belong to no branch and aren’t pushed anywhere. One bad checkout from losing them.

Neither is the tool malfunctioning. Both are a human expecting a live link where there’s a pin. Name the model correctly and these stop being mysteries and start being checklist items.

The discipline: pin deliberately, in its own commit

The workflow that makes submodules calm is boring and repeatable, which is the compliment it deserves. A change to a child repo lands like this:

  1. Do the work inside the submodule and land it on the child’s own remote. Branch, commit, open the merge request, get it onto the child’s mainline. The submodule is a real, standalone repo with its own history and review — treat it like one.
  2. Move the submodule’s local checkout onto the branch and fast-forward it. Get out of detached HEAD: check out the actual branch and fast-forward to its tip, so your working copy reflects a real branch and not a floating commit.
  3. In the superproject, stage only the pointer bump and commit it on its own. The parent’s diff for this should be one line: the submodule moved from commit A to commit B. Don’t sweep unrelated changes into that commit. A pointer bump should read, in the history, as exactly what it is.

That third step is the one people skip, and skipping it is how you get a parent commit that bumps three submodules and edits some config and adds a file, so nobody can tell later what actually changed. One pointer move, one commit. Future git-blame will thank you.

Reconcile before you trust the tree

Because submodules can quietly fall out of alignment, the first thing I do in a superproject I haven’t touched in a while is ask git what state everything is actually in, rather than assuming it’s clean:

git submodule status        # a +/- prefix means the checkout doesn't match the pin
git submodule foreach 'git status --short'   # any uncommitted work hiding in a child?

A + or - in front of a submodule’s hash is git telling you the checked-out commit doesn’t match what the parent pinned — reconcile that before you do anything else, or you’ll commit a pointer move you didn’t mean to. Confirm each child is on the branch you think, fast-forwarded, with nothing unpushed. Five minutes of looking saves an afternoon of “wait, which version was actually deployed?”

When the model fits, submodules are great

The reason to put up with any of this is that the pin-not-sync behavior is genuinely the right tool for some jobs. When you want a parent project to compose several independently-versioned repos — each with its own history, review, permissions, and release cadence — while still recording one coherent, reproducible “these exact versions go together” snapshot, that’s exactly what a submodule is. The pin is the feature, not the bug. (It’s the foundation of the superproject-of-submodules layout I reach for when neither a monorepo nor a pile of loose repos fits.)

The whole thing comes down to refusing the wrong metaphor. A submodule is not a live feed of another repo. It’s a deliberate, version-controlled pointer to one commit, and your job is to move it on purpose and never by accident. Hold that model and submodules are calm; forget it and you’ll swear they’re cursed. If you’ve untangled your own submodule mess and want to compare scars, I’m easy to reach.