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

Submodule versus subtree, on the axes that matterA submodule gives a recorded upstream SHA and a small repository but requires recursive clones and extra CI steps. A subtree gives a self-contained clone and no special tooling but grows the repository and records no upstream identifier unless you add one yourself.submodulesubtreecloneneeds --recurse-submodulesplain git clone just worksprovenancegitlink records the exact SHAnothing recorded unless you do itrepo sizeone gitlink, a few bytesthe files, permanentlycontributingyou are in the upstream repogit subtree push, and it is fiddlyupdatingmove the pin, one-path commita merge; conflicts if you patched

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 with git -C vendor/libfoo log --branches --not --remotes before 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"
Provenance is automatic with a gitlink and manual with a subtreeA submodule's gitlink is itself the provenance record, verifiable with a single command. A subtree has no such record, so the upstream URL, commit and tag must be written into a file and a merge commit message, and kept current on every update.submodule — provenance is structural160000 b2c3d4e vendor/libfoothe tree entry IS the recordit cannot drift from realitysubtree — provenance is a promise100644 … vendor/libfoo/src/main.cordinary files — nothing says where fromthe UPSTREAM file is the only record you get

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
A subtree update is a merge, not a pin moveFetching upstream and pulling the subtree produces a squashed merge into the prefix directory. If the tree has been patched locally, the merge conflicts, which forces the patch to be reconsidered rather than silently overwritten.git fetchupstream historygit subtree pullsquashed merge into the prefixtree untouched locally → clean mergeupdate the UPSTREAM file and commitlocally patched → conflictthe patch is reconsidered, not lost

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.