Skip to content

Git Subtrees

Git subtrees embed an external repository’s files directly into a subdirectory of your project, making them part of your repository’s object database. Unlike submodules which maintain a reference to an external repo, subtrees are just regular files that can be committed, branched, and merged normally. The squash option collapses the upstream history into a single commit for a cleaner parent history, while keeping the full history preserves provenance. Subtrees eliminate the complexity of submodule management at the cost of larger repository size.

git subtree merges a repository into a subdirectory of another repository. Unlike submodules, Which maintain a reference to an external repository, subtrees embed the external project”s files (and optionally its history) directly into the parent repository’s object database.

There are two key variants:

  • --squash: Collapses the upstream repository’s history into a single synthetic commit. The parent repo records “added subtree at commit X” as one commit. This keeps the parent history clean but loses the upstream’s individual commit history.
  • Without --squash: Replays every upstream commit into the parent repository. The full history is interleaved — parent commits and subtree commits are mixed together in the log. This preserves provenance but pollutes the parent’s history.

Subtrees are implemented as a set of Git commands built on top of git merge and git read-tree. They are not a separate Git object type. The subtree’s files are regular files in the parent Repository’s tree. There is no special mode bit (unlike submodules, which use mode 160000). To Git, the subtree is just a directory with files in it.

parent-repo/
├── src/
│ ├── app.py
│ └── utils.py
├── vendor/lib/ <-- subtree (regular directory, regular files)
│ ├── lib.c
│ ├── lib.h
│ └── Makefile
└── README.md

There is no .gitmodules file. There is no .git directory inside vendor/lib/. The files exist As normal blobs in the parent repository’s object database. Any Git operation on the parent (touching files in vendor/lib/Committing changes, branching, merging) works exactly the same as It would for any other directory.

Terminal window
git subtree add \
--prefix=<subdirectory> \
[--squash] \
<repository-url> \
<branch-or-ref> \
[--message=<message>]
Terminal window
$ git subtree add --prefix=vendor/lib --squash https://github.com/org/library.git main
git fetch https://github.com/org/library.git main
warning: no common commits
Author: Your Name <you@example.com>
Date: Mon Jun 2 10:00:00 2025 +0000
Squashed 'vendor/lib/' content from commit a3f2b1c
a3f2b1c Initial commit for library
lib.c | 45 +++++++++++++
lib.h | 23 +++++++
Makefile | 18 +++++++
3 files changed, 86 insertions(+)
create mode 100644 vendor/lib/lib.c
create mode 100644 vendor/lib/lib.h
create mode 100644 vendor/lib/Makefile

With --squashThe framework creates a single merge commit whose tree contains the subtree files. The synthetic commit message records the upstream commit SHA that was squashed. This is critical — It is the bookmark that git subtree pull uses to find the starting point for the next pull.

Terminal window
$ git subtree add --prefix=vendor/lib https://github.com/org/library.git main

Without --squashEvery upstream commit is replayed as a separate commit in the parent repository. The commit messages are preserved, and git log shows the full interleaved history:

Terminal window
$ git log --oneline vendor/lib/
e5f6a7b Fix buffer overflow in parser
d4c5e6f Add unit tests
c3d4e5f Implement core parsing logic
b2c3d4e Initial commit for library
Terminal window
$ git subtree add --prefix=vendor/lib ../shared-library main

Local paths work identically to remote URLs. The repository is fetched via the local Git protocol.

Terminal window
$ git subtree add --prefix=vendor/lib --squash https://github.com/org/library.git v2.1.0
$ git subtree add --prefix=vendor/lib --squash https://github.com/org/library.git a3f2b1c
  1. git fetch <repo-url> <ref>. Fetches the upstream branch/tag.
  2. git read-tree --prefix=<dir> -u FETCH_HEAD. Reads the fetched tree into the index at the specified prefix, updating the working tree.
  3. git commit. Commits the result.

With --squashStep 2 uses a synthetic merge base instead of replaying individual commits. The Merge commit’s message contains the upstream SHA: Squashed '<prefix>/' content from commit <sha>.

Terminal window
git subtree pull \
--prefix=<subdirectory> \
[--squash] \
<repository-url> \
<branch-or-ref>
Terminal window
$ git subtree pull --prefix=vendor/lib --squash https://github.com/org/library.git main

The framework finds the last upstream SHA from the previous squash commit message, fetches the Latest upstream, and creates a new squash commit containing only the changes since the last pull. The result is a linear series of squash commits in the parent:

Terminal window
$ git log --oneline -- vendor/lib/
f7g8h9i Squashed 'vendor/lib/' content from commit x1y2z3
d4e5f6g Squashed 'vendor/lib/' content from commit a3f2b1c
Terminal window
$ git subtree pull --prefix=vendor/lib https://github.com/org/library.git main

New upstream commits are replayed on top of the existing history. The parent’s log now shows the new Upstream commits interleaved with any local commits.

If you modified files in the subtree directory locally, and the upstream also modified those files, You will get merge conflicts:

Terminal window
$ git subtree pull --prefix=vendor/lib --squash https://github.com/org/library.git main
Auto-merging vendor/lib/lib.c
CONFLICT (content): Merge conflict in vendor/lib/lib.c
Automatic squash merge failed; fix conflicts and then commit the result.

Resolve the conflicts, stage the files, and complete the merge:

Terminal window
$ # Edit vendor/lib/lib.c to resolve conflicts
$ git add vendor/lib/lib.c
$ git commit

There is no automatic tracking. You must provide the same <repository-url> and <ref> on every subtree pull. If you want Git to remember the upstream, add a remote:

Terminal window
$ git remote add lib-upstream https://github.com/org/library.git
$ git subtree pull --prefix=vendor/lib --squash lib-upstream main

This is the recommended approach. Name the remote descriptively so it is clear which subtree it Corresponds to.

Terminal window
git subtree push \
--prefix=<subdirectory> \
<repository-url> \
<branch-or-ref>
Terminal window
$ git subtree push --prefix=vendor/lib lib-upstream main

This command extracts all changes in the vendor/lib/ prefix that have been made locally, creates a Temporary branch containing only those changes, and pushes that branch to the upstream repository.

Internally, git subtree push:

  1. Runs git subtree split --prefix=vendor/lib to create a new branch containing only the subtree commits.
  2. Pushes that branch to the upstream remote.

You must have push access to the upstream repository. If you do not, the push will fail with a Permission error. This is by design — subtrees assume a trust relationship between the parent and Upstream repositories.

Only changes under the specified prefix are pushed. Changes in other directories of the parent Repository are not included. The push creates a linear history on the upstream branch containing Only commits that touched files in the subtree directory.

If you used --squash when adding and pulling the subtree, the upstream repository sees squash Commits, not the original individual commits. This means:

  • The upstream history is a series of “Squashed content from commit X” commits.
  • Individual commit messages from the parent are not preserved on the upstream side.
  • If multiple developers are pushing to the same upstream from different parent repos, the squash commits will conflict.

For bidirectional workflows (pull from upstream AND push to upstream), avoid --squash. Use full History instead.

DimensionSubmodulesSubtrees
Storage modelParent stores a commit reference (mode 160000)Parent stores the actual files as regular blobs
HistorySeparate history in each submodule repoInterleaved with parent (or squashed into single commits)
Clone behaviorRequires --recurse-submodules; directories empty without itSingle clone; everything is in one repo
Repository sizeMinimal (only references)Larger (full file content stored in parent)
Update workflowgit submodule update --remote + git add + commitgit subtree pull --prefix=...
Push workflowWork in submodule repo, push from theregit subtree push --prefix=...
Branching/mergingSubmodule has its own branches; must manage separatelyNo separate branches; subtree files are part of parent
Detached HEADgit submodule update checks out in detached HEADNot applicable (no separate checkout)
ComplexityHigh (two repos, two sets of commands, .gitmodules)Lower (single repo, single set of commands)
CI/CDMust run git submodule update --init --recursiveNo special CI handling needed
Offline workCannot update submodules without networkAlready have all files; can work offline
Contributor frictionHigh — new contributors forget submodule initLow — clone and go
Removinggit rm + git submodule deinit + clean .git/modulesgit rm -rf <prefix> + git remote rm
Best forLarge, independently-developed dependenciesVendor libraries, shared code, small dependencies
  • Vendor dependencies — embedding a third-party library whose source you want to modify or audit.
  • Shared code between projects — a common library used by multiple internal projects where you want to push changes back.
  • Monorepo migration — gradually extracting subdirectories into separate repos (or vice versa).
  • Simplicity — when you want a single clone, single branch, and no submodule friction.
  • Large dependencies — when the upstream repository is large and you do not want it in your repo’s object database.
  • Independent development — when the upstream is actively developed by a separate team and you want to track specific commits without merging their entire history.
  • Multiple consumers — when many projects depend on the same upstream and you want to update them independently.
  • Sparse checkout — when you only need a subset of the upstream’s files.

The inverse of git subtree add is git subtree split. It creates a new branch (or updates an Existing one) containing only the history of a specific subdirectory.

Terminal window
git subtree split \
--prefix=<subdirectory> \
--branch=<new-branch-name> \
[--rejoin] \
[--onto=<branch>]

Creating a New Repository from a Subdirectory

Section titled “Creating a New Repository from a Subdirectory”
Terminal window
## 1. In the parent repository, split the subdirectory into a new branch
$ git subtree split --prefix=vendor/lib -b lib-only
## 2. Create a new bare repository from that branch
$ git init --bare /tmp/lib-repo.git
$ cd /tmp/lib-repo.git
$ git pull ../parent-repo lib-only
# Or push to a remote directly
$ git subtree split --prefix=vendor/lib -b lib-only
$ git push origin lib-only:main

The --rejoin flag merges the split branch back into the current branch as a subtree merge. This is Used when you have made changes to the subtree locally and want to push them upstream:

Terminal window
# Split the subtree directory into a separate branch
$ git subtree split --prefix=vendor/lib --rejoin -b lib-only

After --rejoinThe current branch has a new merge commit that ties the split history back to the Parent. This enables subsequent git subtree push operations to find the correct starting point.

  • git subtree split must replay every commit that touched the subdirectory. On repositories with tens of thousands of commits, this can take minutes.
  • The split only includes commits that modified files in the prefix. If a commit touched both the subtree and other files, the split creates a new commit containing only the subtree changes.
  • Squash commits from git subtree add --squash are treated as single commits during split. The individual upstream history that was squashed is not recoverable.

If a split produces unexpected results (wrong files, missing commits), delete the temporary branch And try again:

Terminal window
$ git branch -D lib-only
$ git subtree split --prefix=vendor/lib -b lib-only

Removing a subtree is simpler than removing a submodule. Since the files are regular files in the Parent repository, you remove them like any other directory:

Terminal window
# 1. Remove the subtree directory
$ git rm -rf vendor/lib
rm 'vendor/lib/lib.c'
rm 'vendor/lib/lib.h'
rm 'vendor/lib/Makefile'
# 2. Commit the removal
$ git commit -m "Remove vendor/lib subtree"
# 3. Remove the remote reference (if you added one)
$ git remote rm lib-upstream
# 4. Clean up any dangling objects (optional)
$ git gc --prune=now

If you want to remove the subtree tracking but keep the files:

Terminal window
# The files are already regular files — just leave them.
# Remove the remote reference:
$ git remote rm lib-upstream
# The files in vendor/lib/ remain as normal tracked files.

There is no .gitmodules to clean up, no .git/modules/ directory to delete. The removal is Exactly the same as removing any directory from a Git repository.

After Removal, the Upstream Still Has Your Pushes

Section titled “After Removal, the Upstream Still Has Your Pushes”

If you pushed changes upstream via git subtree pushThose commits remain in the upstream Repository. Removing the subtree from the parent does not revert the upstream. Coordinate with the Upstream maintainers if you need to revert pushed changes.

The canonical use case: embedding a third-party library as source code.

Terminal window
# Add a library as a subtree
$ git remote add lib-upstream https://github.com/org/library.git
$ git subtree add --prefix=vendor/lib --squash lib-upstream v2.1.0
# Make local modifications
$ vim vendor/lib/lib.c
$ git add vendor/lib/lib.c
$ git commit -m "fix: patch buffer overflow in vendor lib"
# Pull upstream updates (merging your local changes with theirs)
$ git subtree pull --prefix=vendor/lib --squash lib-upstream main
# Push your patches back upstream (if you have access)
$ git subtree push --prefix=vendor/lib lib-upstream main

Using subtrees to compose a monorepo from separate repositories:

Terminal window
# Create the monorepo
$ mkdir monorepo && cd monorepo
$ git init
# Add each component as a subtree
$ git remote add frontend https://github.com/org/frontend.git
$ git remote add backend https://github.com/org/backend.git
$ git remote add shared https://github.com/org/shared-lib.git
$ git subtree add --prefix=packages/frontend --squash frontend main
$ git subtree add --prefix=packages/backend --squash backend main
$ git subtree add --prefix=packages/shared --squash shared main
# Pull updates from individual components
$ git subtree pull --prefix=packages/frontend --squash frontend main
$ git subtree pull --prefix=packages/backend --squash backend main
# Push changes from the monorepo back to individual repos
$ git subtree push --prefix=packages/frontend frontend main

Two projects share a common library. Changes made in either project are pushed back to the shared Library:

Terminal window
# In project-a
$ git remote add shared https://github.com/org/shared-lib.git
$ git subtree add --prefix=libs/shared --squash shared main
# Make changes to the shared library
$ vim libs/shared/utils.py
$ git add libs/shared/utils.py
$ git commit -m "feat: add retry logic to shared utils"
# Push changes back to the shared library
$ git subtree push --prefix=libs/shared shared main
# In project-b, pull the latest changes
$ git subtree pull --prefix=libs/shared --squash shared main

Bootstrapping a New Project from an Existing Subdirectory

Section titled “Bootstrapping a New Project from an Existing Subdirectory”
Terminal window
# Extract a subdirectory from an existing repo into a new standalone repo
$ git subtree split --prefix=packages/api -b api-standalone
$ git push origin api-standalone:main

This is the single most important decision when using subtrees, and getting it wrong causes Significant pain later.

Using --squash:

  • Clean parent history (one commit per pull).
  • Fast pulls (no replaying of upstream commits).
  • Cannot push meaningful history upstream (push creates a flat, synthetic history).
  • Cannot use git log in the subtree to see individual upstream commits.
  • If you later decide you need full history, you cannot recover it from the squashed commits.

Not using --squash:

  • Full upstream history preserved in the parent.
  • Can push and pull with full history.
  • Parent history is polluted with upstream commits.
  • git log shows interleaved commits from the parent and the upstream.
  • Pulls are slower (every upstream commit is replayed).

Recommendation: Use --squash for one-way dependencies (you pull from upstream but never push Back). Use full history for bidirectional workflows (you both pull and push). This decision should Be made at the time of git subtree add and must remain consistent — mixing squashed and Non-squashed operations on the same subtree leads to conflicts.

If you modified files in the subtree directory and the upstream also modified those same files, git subtree pull produces merge conflicts. The conflict resolution is a standard Git merge Conflict — edit the files, stage them, commit. But the experience is worse than a normal merge Because:

  • The conflict markers appear in files you may not fully understand (they are upstream code).
  • If you used --squashThe conflict is between your local changes and a squash commit containing all upstream changes since the last pull. This can be a large diff.
  • If you did not use --squashThe conflict is between your local changes and individual upstream commits, which can be easier to resolve incrementally.

Every file in the subtree is stored in the parent repository’s object database. If the upstream Repository is large (hundreds of megabytes of source, binary assets, or large history), your parent Repository grows by the same amount. This is unavoidable — it is the cost of embedding the files.

Mitigate by:

  • Using --squash to avoid storing the full upstream history.
  • Using --depth=1 when fetching to minimize the number of objects pulled.
  • Periodically running git gc --aggressive to reclaim space from unreachable objects.

The git subtree pull and git subtree push commands require the repository URL every time. If you Do not add a named remote, you must type the full URL on every command — and it must match exactly What you used for git subtree add:

Terminal window
# WRONG: different URL (even if it resolves to the same repo)
$ git subtree add --prefix=vendor/lib --squash https://github.com/org/library.git main
$ git subtree pull --prefix=vendor/lib --squash git@github.com:org/library.git main
# Error: the merge base cannot be found because the URLs differ
# CORRECT: use the same URL, or better, use a named remote
$ git remote add lib-upstream https://github.com/org/library.git
$ git subtree add --prefix=vendor/lib --squash lib-upstream main
$ git subtree pull --prefix=vendor/lib --squash lib-upstream main

git subtree split replays every commit in the repository’s history that touched the prefix. On a Repository with 100,000 commits, this can take 10+ minutes. There is no way to speed this up — it is An inherent limitation of the algorithm. If you need to split frequently, consider using git filter-repo instead, which is significantly faster.

Concurrent Pushes from Multiple Parent Repos

Section titled “Concurrent Pushes from Multiple Parent Repos”

If two parent repositories both use subtrees pointing to the same upstream, and both push changes, The upstream will have conflicting histories. Git cannot resolve this automatically. The second push Will fail with a non-fast-forward error, and the pusher must pull, resolve conflicts, and push Again. This is fundamentally a coordination problem — subtrees assume a single source of truth for The upstream.

If you git rm -rf vendor/lib and later git subtree add --prefix=vendor/lib again, the framework Cannot reuse the previous history. The new subtree add starts fresh. If you need to re-add a Subtree, consider whether git subtree pull (to update the existing subtree) is what you actually Want.

Subtree Commands Are Slow for Large Prefixes

Section titled “Subtree Commands Are Slow for Large Prefixes”

git subtree add``pullAnd push all involve git fetch``git read-treeAnd git merge. For Large subtrees (thousands of files), these operations can be slow. The framework does not support Partial subtree operations — you always operate on the entire prefix. If performance is a concern And the upstream is large, submodules may be a better choice.

Unlike submodules (which have .gitmodules to record the URL and path), subtrees have no metadata File. The framework relies on the squash commit message (or the interleaved history) to track the Upstream state. If the squash commit message is edited or the history is rewritten, the framework Loses track of the upstream merge base, and subsequent git subtree pull operations may fail or Produce incorrect results.

On Windows, Git may use backslashes in paths, but git subtree expects forward slashes in the --prefix argument:

Terminal window
# WRONG on Windows (may fail depending on Git version)
$ git subtree add --prefix=vendor\lib --squash https://github.com/org/library.git main
# CORRECT: always use forward slashes
$ git subtree add --prefix=vendor/lib --squash https://github.com/org/library.git main

This topic covers the core concepts of git subtrees, including underlying theory, practical implementation, and key applications.

Key concepts include:

  • Git fundamentals (add, commit, push, pull)
  • branching and merging strategies
  • resolving merge conflicts
  • rebasing and cherry-picking
  • Git workflows (GitFlow, trunk-based)

Understanding these concepts thoroughly is essential for both examinations and practical programming, and requires both theoretical knowledge and hands-on practice.

Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.

  • Submodules: Alternative approach to managing external dependencies within a Git repository.
  • Cherry-Pick: Selective commit application technique that complements subtree operations.
  • Worktrees: Multiple working directories for parallel development alongside subtree management.