Back to Blog

Why Does Using Git Submodules Feel Like Playing a Dangerous Game of Jenga With Your Codebase?

Why Does Using Git Submodules Feel Like Playing a Dangerous Game of Jenga With Your Codebase?

There is a specific kind of silence that falls over a Zoom call when someone realizes they just pushed a parent repository but forgot to push the changes inside the submodule. It’s a mixture of frustration and "not again" that every developer who has touched Git submodules knows by heart.

On paper, submodules are the perfect solution for code reuse. You have a library, you want to use it in five different projects, so you link it. Simple. But in practice, Git submodules feel less like a structured dependency and more like a game of Jenga where the pieces are made of glass and the table is vibrating.

It’s a Pointer, Not a Folder

The first thing that trips people up is thinking of a submodule as a regular directory. It isn't. To the parent repository, a submodule is just a special file that contains a specific commit hash.

When you run git status and see:

modified:   libs/shared-ui (new commits)

Git isn't saying "the files in here changed." It's saying "the pointer is now looking at a different commit than the one I have recorded."

This creates the "empty folder" problem. You clone a repo, and the submodule directory is just... empty. You have to run the magic incantation:

git submodule update --init --recursive

If you forget that --recursive flag and you have submodules *inside* submodules? Good luck. You're now playing Russian Doll with your terminal.

The "Detached HEAD" Nightmare

This is where the Jenga tower usually starts to wobble. By default, when you update a submodule, Git checks out a specific commit hash, not a branch.

You go into your submodule, fix a bug, and commit it. You feel great. Then you realize you're in a "detached HEAD" state. If you don't manually checkout a branch (main or develop) before you start working, your commits are effectively floating in space. If you switch branches in the parent repo or run an update, those commits can become incredibly difficult to find again.

I've watched seniors lose hours of work because they assumed the submodule was on main. It wasn't. It was on SHA 4f2a1b, and they just committed SHA 7e892c on top of a ghost.

The Synchronization Tax

The "Dangerous Game" aspect really shines when you try to push your code. To successfully update a feature that spans both the parent repo and a submodule, you have to follow a very specific ritual:

1. Enter the submodule.
2. Commit your changes.
3. Push the submodule changes to the remote. (If you forget this, your CI/CD will fail because it can't find the commit hash you're about to reference).
4. Go back to the parent repo.
5. git add the submodule change (the new pointer).
6. Commit and push the parent repo.

If you do these out of order, or if a teammate pulls the parent repo before you’ve pushed the submodule, they get the dreaded "fatal: reference is not a tree" error. You've essentially pointed them to a map coordinate that doesn't exist yet on their version of the world.

Why Do We Keep Doing This?

With all this friction, why use them? Because sometimes, the alternatives are worse.

* NPM/PyPI/RubyGems: Great, until you need to edit the library and the app simultaneously. Doing npm link is its own special hell.
* Monorepos: Fantastic for some, but a nightmare for permissions and build times if your codebase is massive.
* Copy-pasting: Just don't.

Submodules offer a "hard link" between projects that ensures your build is reproducible. If I checkout a commit from 2022, the submodule will (theoretically) checkout exactly what was used in 2022. That’s powerful, even if it is painful.

How to play the game without losing

If you're stuck with submodules, you have to change how you interact with Git. I've found a few habits keep the Jenga tower standing:

1. Use the "Foreach" command: If you need to see what's happening across all your submodules, don't cd into each one. Use:
`bash
git submodule foreach 'git status'
`
2. Alias the update command: Don't rely on memory. Alias gsu to git submodule update --init --recursive.
3. Check your diffs: Before you push the parent repo, look at the diff. If you see a submodule hash change, ask yourself: "Did I push the code inside that folder first?"

Git submodules are a tool of last resort that we use as a primary strategy way too often. They require a level of manual discipline that Git usually handles for us. If you can avoid them, do. If you can’t, treat every git push like you're pulling the bottom block out of the tower—slowly, and with your breath held.