Why My Submodules Kept Breaking and How Git Subtree Finally Saved the Project
I remember staring at a failed CI pipeline for forty minutes, convinced the YAML was broken, only to realize a junior dev had pushed a feature branch without running git submodule update. The main repo was pointing to a commit hash in the submodule that didn't exist on the remote yet. We had a broken build, a frustrated team, and a dependency management system that felt more like a hostage situation.
The Submodule Trap
Git submodules are technically elegant but practically exhausting. They work by recording a specific commit SHA from another repository and storing it as a pointer. On paper, this is great for versioning. In practice, it creates a "nested state" that most developers forget to manage.
If you’ve used submodules, you know the drill:
1. You clone a repo and wonder why the lib folder is empty.
2. You realize you forgot --recursive.
3. You run git submodule init and git submodule update.
4. You make a change inside the submodule, forget to push it, push the parent repo, and effectively break the project for everyone else.
It’s a workflow that demands perfection from every contributor. One person forgets the dance, and the repo state desyncs.
Why Subtree is Different
Git subtree takes a different approach. Instead of storing a pointer to a remote commit, it injects the entire source code of the dependency into your repository as a subdirectory.
To your teammates, a subtree looks like a regular folder. They don’t need to learn new commands. They don’t need to init anything. If they can git pull, they have the code. The "magic" happens only for the person managing the dependency.
Bringing in a Subtree
Let's say you have a shared library at https://github.com/user/shared-ui. You want it in your project under imports/shared-ui.
Here is the command to bake it in:
git remote add shared-origin https://github.com/user/shared-ui.git
git subtree add --prefix imports/shared-ui shared-origin main --squashThe --squash flag is vital here. Without it, you’ll import every single commit from the library’s history into your main project’s log. Unless you really need to see what the library's intern was doing three years ago, squash it. Your git log will thank you.
Updating the Dependency
When the library gets an update, you don't ask your team to run some obscure update command. You pull the changes yourself, commit them, and everyone else gets the code naturally on their next pull.
git subtree pull --prefix imports/shared-ui shared-origin main --squashThis creates a merge commit in your main repo. If there are conflicts, you resolve them right there in your project editor, just like any other merge. There’s no switching contexts or jumping between detached HEAD states.
Pushing Back to the Source
Sometimes you fix a bug in the shared library while working on the main project. With submodules, this is a multi-step nightmare. With subtree, you just commit the fix to your main repo and then "push" that specific folder back to the library's remote:
git subtree push --prefix imports/shared-ui shared-origin mainGit is smart enough to figure out which commits touched that specific prefix and only sends those changes back to the upstream repository.
The Trade-offs (Because there are always trade-offs)
I’m a subtree convert, but it isn't a silver bullet.
* Repo Size: Since the code lives in your repo, your .git folder grows. If you're pulling in a 2GB asset library, subtree is a bad choice.
* Command Complexity: The commands are long. I usually end up writing a small Taskfile or a few bash aliases because I can never remember the exact syntax for --prefix.
* Merge Overhead: If you make massive changes in the subtree and the upstream repo changes simultaneously, the merge can get messy.
When to Make the Switch
If your project is a monorepo-lite or you’re managing internal libraries that change frequently, submodules are likely slowing you down. The moment I switched to subtrees, our "Why is the build broken?" Slack messages dropped by about 90%.
Stop treating your dependencies like fragile external pointers. If the code is important enough to be in your project, it’s important enough to actually be *in* your project.