Why I Chose Git Subtrees Over Submodules for Sharing Code Across Repos

We had one shared library used by four services in four separate repositories, and every developer who touched it lost half a day to a detached HEAD. That was the moment I stopped defending git submodules and moved the whole thing to subtrees.

The problem is ordinary and everywhere: you have code that belongs in more than one repository — a set of API clients, shared validation logic, a common set of database migrations — and you do not want to copy-paste it and let the copies drift. Git gives you two native answers, submodules and subtrees, and the internet is full of people who will tell you one is objectively correct. It is not that simple. What follows is the comparison I actually ran, the commands that did the work, and the reasoning that made subtrees the default for my team while leaving submodules in place for the one case where they still win.

What submodules actually cost you

A submodule is a pointer. Your parent repository records the URL of another repository and a single commit SHA inside it. The shared code does not live in your tree; a reference to it does. Adding one looks clean:

Free · 4 minutes

If your most senior engineer left tomorrow, would anyone still understand the system?

Fourteen questions on documentation, dependencies, and the gap between how the architecture works and how many people know it. Banded finding on screen, full sheet by email.

# add the shared library as a submodule
git submodule add git@github.com:acme/shared-lib.git libs/shared
git commit -m "Add shared-lib as submodule"

# a fresh clone does NOT pull submodule contents by default
git clone git@github.com:acme/service-a.git
git submodule update --init --recursive   # the step everyone forgets

The friction is not in the add. It is in daily life. A submodule is checked out at a specific commit, not a branch, so the moment anyone opens it they are in detached HEAD state. Make an edit, commit it, and unless you first ran git checkout main inside the submodule, that commit is dangling — attached to no branch, quietly eligible for garbage collection. I have watched capable engineers lose an afternoon’s work exactly this way.

Bumping the pinned version is a two-repository dance every single time:

# update the submodule to the latest upstream main
cd libs/shared
git checkout main
git pull
cd ..
git add libs/shared          # stages the new pinned SHA
git commit -m "Bump shared-lib to latest"

Multiply that by four services and by every developer, some of whom will forget --init --recursive on clone and build against an empty directory. CI has to be taught the same dance. New joiners hit it in week one. None of it is hard; all of it is a tax, and the tax is paid in the currency that shows up in your delivery metrics — the lead time and change-failure numbers a submodule mishap quietly inflates.

What a subtree does differently

A subtree takes the opposite bet. Instead of pointing at another repository, it copies that repository’s files and history into your tree, under a prefix, as real commits. There is no pointer, no second checkout, no detached HEAD, because there is no second repository on the consumer’s disk — just files, like any others.

# bring shared-lib in under libs/shared, squashing its history
git remote add shared-lib git@github.com:acme/shared-lib.git
git subtree add --prefix=libs/shared shared-lib main --squash

Now the decisive part: a fresh clone of the service just works. git clone and the code is there. No submodule update, no init flag, nothing to forget. A new engineer or a CI runner that knows nothing about subtrees still gets a complete, buildable checkout, because as far as the tooling is concerned the files are native. That single property is why I moved. It removes an entire class of onboarding and build failures rather than documenting a way around them.

Pulling upstream changes is one command, run from the consuming repo:

# pull the latest shared-lib changes into libs/shared
git subtree pull --prefix=libs/shared shared-lib main --squash

The --squash flag matters. Without it, every commit from the library’s history is grafted into your service’s history, and four consumers means the library’s log appears four times across your estate. With --squash, each pull collapses to a single merge commit — you keep the code and the upstream reference without drowning your own history. Be consistent: if you squash on add, squash on every pull, or the merge base gets confused.

Pushing changes back upstream

The objection people raise is contribution: if the code lives inside my service, how does a fix get back to the library everyone shares? This is the workflow the submodule crowd assumes you cannot do, and it is genuinely a little more involved — but it is one command.

# you edited files under libs/shared and committed them normally;
# now send just those changes back to the library repo
git subtree push --prefix=libs/shared shared-lib main

# or split the prefix into a standalone branch for review first
git subtree split --prefix=libs/shared -b shared-updates
git push shared-lib shared-updates:main

git subtree split is the engine underneath. It walks your history, extracts every commit that touched the prefix, and rewrites them into a synthetic branch as if the library had been developed on its own — which you can then push, or open as a pull request upstream. In practice most fixes are made in the library repo directly and pulled down; split is the escape hatch for the times a fix is discovered inside a consumer and needs to travel back.

Where submodules are still the right call

This is not a blanket verdict, and anyone who gives you one is selling a preference as a principle. Submodules still win in specific conditions:

  • The dependency is large. Subtrees copy the tree into every consumer. A multi-gigabyte vendored dependency across ten repos wastes real storage and clone time; a submodule pointer does not.
  • You need an exact, auditable pinned commit. A submodule records a precise SHA in the parent. For regulated builds where you must prove which exact revision of a third-party component shipped, that explicit pin is evidence a squashed subtree merge obscures.
  • The shared code is developed almost entirely upstream. If consumers only ever read a library and never contribute, the submodule’s read-only pointer model is a fair fit and the ceremony is rare.

For everything else — small-to-medium shared libraries, edited from more than one place, cloned by people and pipelines that should not have to know the plumbing — subtrees removed a recurring failure mode instead of writing a runbook around it. The test I now apply is simple: if a brand-new clone has to run a second command before it builds, that command will be forgotten, and the cost of forgetting it lands on whoever is least equipped to diagnose it. Subtrees mean there is no second command. That is the whole argument, and it is the same discipline as making your baseline tooling run without anyone remembering to invoke it — the setup that depends on human memory is the setup that breaks.

Free interactive tool

Website compliance checklist

What your site has to do, based on what it actually does

Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.

Can you trust the architecture you have?

Architecture diagrams rarely show the reality of how systems actually operate. An independent review establishes what is really there.