GitSubtrees

Git Subtree

git subtree is an alternative to submodules for nesting one repository inside another. Instead of recording a pointer to a separate repo, subtree merges the entire history of the external repo into a subdirectory of the parent repo. The result is a single, unified history with no hidden metadata files, no detached HEADs, and no extra commands needed after a plain git clone.

Mental model

What subtree looks like in your repo

Text
myproject/
├── src/
│   └── app.js
├── lib/                ← external repo merged here
│   ├── components/
│   └── index.js
└── package.json

The entire "lib/" directory lives in YOUR repo's history.
No .gitmodules, no .git/modules/, no special clone flags needed.
Adding a subtree

Bash
# Add a remote for the library (optional but recommended — makes pulls/pushes easier)
git remote add ui-lib https://github.com/example/ui-components.git
git fetch ui-lib

# Merge the entire history of ui-lib/main into the prefix "libs/ui"
git subtree add --prefix=libs/ui ui-lib main --squash

Terminal output

Text
git fetch ui-lib
From https://github.com/example/ui-components
 * [new branch]      main -> ui-lib/main

git subtree add --prefix=libs/ui ui-lib main --squash
git fetch ui-lib main
From https://github.com/example/ui-components
 * branch            main     -> FETCH_HEAD
Added dir 'libs/ui'

The --squash flag condenses the entire history of the external repo into a single merge commit, keeping your main log clean. Without it, every commit from the external repo is replayed into your history — useful for full attribution, but often noisy.

Pulling updates from the upstream repo

Bash
# Fetch the latest commits from the external repo and merge them in
git subtree pull --prefix=libs/ui ui-lib main --squash

Output after a successful pull

Text
From https://github.com/example/ui-components
 * branch            main     -> FETCH_HEAD
Squash commit -- not updating HEAD
Merge made by the 'ort' strategy.
 libs/ui/src/Button.tsx | 12 ++++++++++--
 1 file changed, 10 insertions(+), 2 deletions(-)
Pushing changes back to the upstream repo

If you fix a bug or add a feature inside the subtree directory, you can push those changes back to the upstream repo — a major advantage over simply copying files.

Bash
# Extract commits that touched libs/ui and push them to ui-lib/main
git subtree push --prefix=libs/ui ui-lib main

Push output

Text
git push using:  ui-lib main
Counting objects: 5, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (4/4), done.
Writing objects: 100% (5/5), 521 bytes | 521.00 KiB/s, done.
To https://github.com/example/ui-components
   b3c4d5e..f6a7b8c  f6a7b8c -> main
subtree push can be slow on large repos
`git subtree push` traverses the entire commit history to find commits that touched the prefix. On large repos with long histories this can take minutes. Consider using `--rejoin` to cache the split result and speed up future pushes.
The --rejoin flag (performance optimisation)

Bash
# Add a synthetic merge commit that marks the split point
# Subsequent splits will start from here instead of re-scanning everything
git subtree push --prefix=libs/ui ui-lib main --rejoin

# Pull also benefits from --rejoin
git subtree pull --prefix=libs/ui ui-lib main --squash --rejoin
Full workflow example

Day-to-day subtree workflow

Bash
# --- Initial setup (once) ---
git remote add ui-lib https://github.com/example/ui-components.git
git fetch ui-lib
git subtree add --prefix=libs/ui ui-lib main --squash
git push origin main   # push the merge commit to your team

# --- Regular updates ---
# Fetch latest and merge it in
git subtree pull --prefix=libs/ui ui-lib main --squash

# --- Editing files in the subtree ---
vim libs/ui/src/Modal.tsx
git add libs/ui/src/Modal.tsx
git commit -m "fix: close Modal on Escape key"

# --- Contribute the fix back upstream ---
git subtree push --prefix=libs/ui ui-lib fix/modal-escape

# Open a PR on github.com/example/ui-components from fix/modal-escape -> main
Splitting a subdirectory into its own repo

git subtree split is also useful for extracting part of a monorepo into a standalone repo, keeping the relevant history.

Bash
# Extract all commits that touched "src/payments/" into a new branch
git subtree split --prefix=src/payments -b payments-history

# Create a new bare repo and push the extracted history to it
git init --bare /tmp/payments-repo.git
git push /tmp/payments-repo.git payments-history:main

# Clean up the split branch
git branch -D payments-history
git subtree vs git submodules

Aspect

git subtree

git submodules

Clone experience

Plain git clone — no extra steps

Requires --recurse-submodules or manual init/update

History

Merged into the parent repo (one unified history)

Separate history in an independent repo

Pinning

Squash commit = implicit pin; explicit SHAs also possible

Explicit commit SHA stored in superproject index

Contributing back upstream

git subtree push --prefix=...

Push from inside the submodule directory

Detached HEAD risk

None

Constant — must checkout a branch before committing

.gitmodules file

None

Required

Offline access

Always available (it's in your repo)

Always available (already cloned)

Repo size

Larger — external history squashed in

Smaller — only a pointer stored

Team learning curve

Low — standard Git commands

Medium — extra mental model required

Updating upstream

One command (subtree pull)

One command (submodule update --remote) + commit pointer

Use cases
  • Vendoring a library: lock a specific version of a third-party lib into your repo for reproducible builds, with no package-registry dependency.

  • Shared component libraries: a design-system repo that multiple product repos embed and occasionally contribute back to.

  • Documentation sites: embed a shared docs theme or layout repo without separate install steps in CI.

  • Migrating a monorepo to microrepos: use git subtree split to carve out subdirectories with their full history.

  • Bootstrapping a new project: start from a template repo by adding it as a subtree, then diverge freely.

Common errors

Error

Cause

Fix

Working tree has modifications. Cannot add.

You have uncommitted changes when running subtree add.

Commit or stash your changes first.

prefix … already exists

The target directory already exists in the repo.

Use a different prefix path, or remove the existing directory first.

Push is extremely slow

git subtree push re-scans all history on every run.

Use --rejoin to cache the split point.

Updates were rejected because the remote contains work that you do not have locally

Upstream has commits you have not yet pulled.

Run git subtree pull --prefix=... --squash first, then push.

Warning
Avoid mixing `--squash` and non-squash pulls on the same prefix. Switching modes mid-way confuses Git's merge base detection and can produce large spurious diffs. Pick one strategy at the start and stick to it across the project.
Tip
Add a `Makefile` or `package.json` script for the common subtree commands so that every developer runs them the same way. A simple `make ui-update` that wraps `git subtree pull --prefix=libs/ui ui-lib main --squash` saves time and prevents flag-mix-up errors.