Converting a submodule to a subtree Jump to heading
Submodules give you provenance and cost you convenience. Every clone needs --recurse-submodules, every CI job needs an extra step, and every contributor eventually hits the empty-directory problem at least once. A subtree inverts the trade: the dependency’s files are simply part of your repository, so a plain git clone produces a working build — and the recorded upstream SHA that made the submodule trustworthy disappears unless you deliberately replace it. This recipe performs the conversion while keeping that provenance, within Submodule & Dependency Integrity.
When to use this approach Jump to heading
- Contributors regularly end up with an empty dependency directory because they forgot
--recurse-submodules. - CI configuration has accumulated submodule-specific steps in several places.
- The dependency is consumed rather than co-developed: you upgrade it occasionally and rarely patch it.
- Build tooling struggles with nested repositories, which is common with vendoring-oriented package managers.
- If you contribute changes upstream frequently, stay with submodules — working inside a submodule checkout is working in the upstream repository, and that is hard to beat.
Step 1 — Decide whether the trade is worth it Jump to heading
The row that decides most conversions is the first one. If the empty-directory problem is costing your team hours a month, convenience is worth buying — provided you replace the provenance you are giving up, which is what Step 5 does.
Step 2 — Record the current pin before removing anything Jump to heading
# The exact upstream commit currently in use — you will need this
pin=$(git rev-parse HEAD:vendor/libfoo)
url=$(git config -f .gitmodules submodule.vendor/libfoo.url)
printf 'url=%s\npin=%s\n' "$url" "$pin" | tee /tmp/libfoo-provenance
# What tag, if any, does that commit correspond to?
git -C vendor/libfoo describe --tags --exact-match "$pin" 2>/dev/null || echo "(untagged commit)" What changed: nothing — but this is the only moment the pin is trivially available. After the submodule is removed, recovering it means reading history.
Step 3 — Remove the submodule cleanly Jump to heading
Submodule removal touches four places, and skipping one leaves a repository that behaves strangely for months.
# 1. Deinitialise: removes the working tree copy
git submodule deinit -f vendor/libfoo
# 2. Remove the gitlink and the .gitmodules entry
git rm -f vendor/libfoo
# 3. Remove the internal clone Git keeps for it
rm -rf .git/modules/vendor/libfoo
# 4. Remove any leftover config in the local repository
git config --remove-section submodule.vendor/libfoo 2>/dev/null || true # Verify nothing remains
grep -c 'libfoo' .gitmodules 2>/dev/null || echo ".gitmodules clean"
ls .git/modules 2>/dev/null | grep libfoo || echo "internal clone removed"
git ls-files --stage | grep 160000 | grep libfoo || echo "gitlink removed" SAFETY WARNING — step 3 deletes
.git/modules/vendor/libfoo, which contains the submodule’s own object store. Any commits made inside the submodule and not pushed upstream are destroyed with it. Check withgit -C vendor/libfoo log --branches --not --remotesbefore deinitialising; if that prints anything, push it or extract it first, because no reflog in the superproject will bring it back.
Step 4 — Add the same upstream as a subtree Jump to heading
# Commit the removal first so the two changes are separable
git commit -m "deps: remove libfoo submodule (converting to subtree)"
# Add the upstream remote so the subtree commands have somewhere to fetch from
git remote add libfoo-upstream "$url"
git fetch libfoo-upstream
# Import the SAME commit that was pinned, squashed into one commit
git subtree add --prefix=vendor/libfoo libfoo-upstream "$pin" --squash What changed: the dependency’s files are now ordinary tracked files in your repository at exactly the commit that was previously pinned, and --squash means you imported one commit’s worth of content rather than the whole upstream history.
# The tree must be identical to what the submodule contained
git show --stat HEAD | head -5
git log --oneline -2 # a squash commit and its merge
# Prove the content matches the recorded pin
git ls-tree -r HEAD -- vendor/libfoo | sha256sum
git ls-tree -r "$pin" | sed 's|\t|\tvendor/libfoo/|' | sha256sum # same digest Step 5 — Establish the update procedure and keep the provenance Jump to heading
A subtree records nothing about where its contents came from. Replace that explicitly, or the dependency becomes anonymous within a year.
cat > vendor/libfoo/UPSTREAM <<EOF
url: $url
commit: $pin
tag: v3.2.1
note: imported as a subtree on 2026-07-31; update with the command below
git subtree pull --prefix=vendor/libfoo libfoo-upstream <tag> --squash
EOF
git add vendor/libfoo/UPSTREAM
git commit -m "deps: record libfoo upstream provenance" Updating from then on is a merge rather than a pin move:
# Fetch and import a new upstream release
git fetch libfoo-upstream --tags
git subtree pull --prefix=vendor/libfoo libfoo-upstream v3.3.0 --squash
# Update the recorded provenance in the same change
sed -i "s/^commit: .*/commit: $(git rev-parse libfoo-upstream/main)/" vendor/libfoo/UPSTREAM
sed -i "s/^tag: .*/tag: v3.3.0/" vendor/libfoo/UPSTREAM
git add vendor/libfoo/UPSTREAM && git commit --amend --no-edit That conflict is a feature. A submodule pin move silently discards local patches because there are none to discard — the working tree is upstream’s. A subtree merge surfaces them, which is exactly when you want to be asked whether the patch is still needed.
Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Does a subtree make the repository bigger? Jump to heading
Yes, by the size of the dependency’s history that you import. With --squash the increase is one commit’s worth of content rather than the full upstream history, which is usually the right trade for a third-party dependency. Without it, every upstream commit becomes part of your repository and every clone pays for all of it forever.
Can I still contribute changes back upstream? Jump to heading
Yes — git subtree push extracts your changes to the subtree path as commits against upstream. It is clumsier than working in a submodule checkout, where you are simply in the upstream repository, so if contributing back is a frequent activity a submodule remains the better fit. For dependencies you consume and rarely patch, the subtree’s simplicity wins.
How do I know which upstream commit a subtree corresponds to? Jump to heading
Only from what you record. Unlike a gitlink, a subtree stores no upstream identifier — the files are simply part of your tree. Write the URL, tag and commit into the merge commit message and into an UPSTREAM file next to the code, or the provenance is genuinely gone the moment the person who did the import forgets.
Related Jump to heading
- Submodule & Dependency Integrity — the parent guide: pinning, verification, and what each arrangement guarantees.
- Pinning and Updating Git Submodules Safely — the practice you keep if you decide not to convert.
- Auditing Vendored Dependencies for Tampering — how to make a subtree’s provenance verifiable rather than merely written down.