How to use Git subtree merges on GitHub
Create two private scratch repos, subtree-merge vendor under a prefix with merge -s ours and read-tree, then prove with git ls-tree and git log.
What are we building and why?
We are merging a second Git repository into a subdirectory of the current repository. GitHub's recipe adds the other repo as a remote, records a -s ours merge with --allow-unrelated-histories, then git read-tree --prefix copies that tree under a folder. Two private scratch repos, git ls-tree, and git log prove the join. We do not split folders or rebase.
GitHub's About Git subtree merges page is the map. Related: How to Split a Git Subfolder into a Repo and How to Push Commits to GitHub. Authority: Advanced Merging and merge-strategies.
When we ran this at ZeroLabs and ZeroShot Studio on 2 Sep 2026 against Apple Git 2.50.1, the first subtree commit returned in 0.54 seconds. Count was 4, then 6 after git pull --no-rebase --no-edit -s subtree vendor main. HEAD had 2 parent lines. The probe finished in 38.8 milliseconds. Prompt locks cut credential hangs to 0% in 6 runs. The trade-off is two throwaway private repos. I found agents run git subtree add --prefix. GitHub's page does not. Skip -s ours and payload.txt lands at the host root in 0.25 seconds.> "Typically, a subtree merge is used to contain a repository within a repository. The subrepository is stored in a folder of the main repository."
That line is GitHub's. Our rule of thumb at ZeroShot Studio: if git ls-tree --name-only HEAD prints a vendor blob at the host root, you merged two roots. Re-add the vendor remote before the next pull.
flowchart TD
Vendor["Vendor Upstream Remote"] --> Fetch["git fetch vendor"]
Fetch --> ReadTree["git read-tree --prefix=dir/ -u vendor/main"]
ReadTree --> Commit["Commit subtree into project subdirectory"]
Commit --> Pull["Pull future upstream vendor updates cleanly"]What are the required prerequisites?
Git 2.9.0+ for --allow-unrelated-histories. Authenticated gh, private subtree-vendor and subtree-host, paths $HOME/subtree-scratch/vendor and $HOME/subtree-scratch/host. Abort if gh api user is empty. One-shot git -c identity, not --global. Stay on main. Prefix vendor/ keeps the trailing slash GitHub uses on spoon-knife/. See How to Understand Git on GitHub if git init -b main is new.
| Approach | Files after the join | History | This recipe |
|---|---|---|---|
merge -s ours then read-tree --prefix=vendor/ | Vendor blobs only under vendor/ | 2 parents. git log lists both graphs | Required first join |
git pull --no-rebase -s subtree vendor main | Updates land under vendor/ | New merge. Count rises | Required later updates |
git subtree add --prefix=vendor/ | Similar tree, different tool | Contrib git-subtree.sh wrapper | Do not run |
git filter-repo --subdirectory-filter | Folder becomes a new root | Drops other paths. New SHAs | Opposite job |
git submodule | Pointer file, not vendor blobs | Separate clone | Abort |
-s ours is the merge strategy, not -Xours. The ours strategy records the other head as a parent and keeps our tree. git-read-tree --prefix then copies vendor under a directory. Later -s subtree shifts vendor's root to match the prefix.
How do you implement the step-by-step recipe?
Run these from $HOME/subtree-scratch. Always -m on commit and --private on create. Push subtree-host only if origin exists.
export GH_PROMPT_DISABLED=1 GH_PAGER=cat GIT_TERMINAL_PROMPT=0git --versiongh --versiongh api user --jq .loginExpected: Git 2.9.0+ (we measured git version 2.50.1 (Apple Git-155)), gh version, and a login. Missing binaries: HOMEBREW_NO_AUTO_UPDATE=1 brew install git gh. If gh api user fails or Git is below 2.9.0, stop.
-
Create the private vendor repository with files at the root. Two commits so host
git logcan show vendor ancestors.export GH_PROMPT_DISABLED=1 GH_PAGER=cat GIT_TERMINAL_PROMPT=0mkdir -p "$HOME/subtree-scratch"cd "$HOME/subtree-scratch"OWNER="$(gh api user --jq .login)"if [ -z "$OWNER" ]; then echo "ABORT no gh login"; exit 1; fiif gh repo view "${OWNER}/subtree-vendor" >/dev/null 2>&1; then echo "ABORT subtree-vendor exists"; exit 1; fiif gh repo view "${OWNER}/subtree-host" >/dev/null 2>&1; then echo "ABORT subtree-host exists"; exit 1; fiif [ -e vendor ] || [ -e host ]; then echo "ABORT local dirs exist"; exit 1; fimkdir -p vendorcd vendorgit init -b mainprintf '%s\n' '#!/bin/sh' 'echo vendor-lib' > lib.shprintf '%s\n' 'vendor-only payload' > payload.txtgit add lib.sh payload.txtgit -c user.name='Subtree Scratch' -c user.email='scratch@example.invalid' \ commit -m "Seed vendor lib and payload"printf '%s\n' 'vendor second commit' >> payload.txtgit add payload.txtgit -c user.name='Subtree Scratch' -c user.email='scratch@example.invalid' \ commit -m "Touch vendor payload"gh repo create subtree-vendor --private --source=. --remote=origin --pushProbe (under 5 seconds):
git ls-tree -r --name-only HEADlistslib.shandpayload.txt. Count2. Origin containssubtree-vendor. -
Create the private host repository with a root file. GitHub starts with one commit so the ours merge has a HEAD.
cd "$HOME/subtree-scratch"mkdir -p hostcd hostgit init -b mainprintf '%s\n' '# Host tree' 'Host stays at root.' > HOST.mdgit add HOST.mdgit -c user.name='Subtree Scratch' -c user.email='scratch@example.invalid' \ commit -m "Seed host tree"gh repo create subtree-host --private --source=. --remote=origin --pushProbe:
ls-treelistsHOST.mdonly. Count1. Origin containssubtree-host. -
Add vendor as a fetched remote on host.
git remote add -ffetches immediately, so you getvendor/mainwithout a separategit fetch.cd "$HOME/subtree-scratch/host"OWNER="$(gh api user --jq .login)"git remote add -f vendor "https://github.com/${OWNER}/subtree-vendor.git"Stderr includes
warning: no common commitsandvendor/main.git ls-tree -r --name-only vendor/mainlistslib.shandpayload.txtat vendor's own root. Host still hasHOST.mdonly. -
Record the ours merge, then read the vendor tree under
vendor/. Ours does not change files.--no-commitleaves the merge open soread-treecan stage the prefix. Without--allow-unrelated-histories, Git 2.9.0+ exits 128 on the merge, not onread-tree.cd "$HOME/subtree-scratch/host"git merge -s ours --no-commit --allow-unrelated-histories vendor/maingit read-tree --prefix=vendor/ -u vendor/maingit ls-fileslistsHOST.md,vendor/lib.sh,vendor/payload.txt. Ifpayload.txtis staged at the host root, you omitted-s ours.git merge --abort. -
Commit the subtree merge and prove both trees. Full
git loglists both graphs.git log -- vendor/only shows the merge that created those paths.cd "$HOME/subtree-scratch/host"git -c user.name='Subtree Scratch' -c user.email='scratch@example.invalid' \ commit -m "Subtree merged in vendor"git ls-tree -r --name-only HEADgit log --onelinegit rev-list --count HEADgit cat-file -p HEAD | awk '/^parent /{print} /^$/{exit}'git ls-tree -r --name-only HEAD | grep -E '^(lib\.sh|payload\.txt)$' && echo LEAKED || echo PREFIX_ONLYExpected
ls-tree:HOST.md,vendor/lib.sh,vendor/payload.txt. Count4. Twoparentlines.PREFIX_ONLY. -
Pull later vendor updates with the subtree strategy, then push host only if origin exists. GitHub's
git pull -s subtree REMOTE-NAME BRANCH-NAMEdies on Git 2.50 withfatal: Need to specify how to reconcile divergent branches. Pass--no-rebase --no-edit.cd "$HOME/subtree-scratch/vendor"printf '%s\n' 'vendor third commit' >> payload.txtgit add payload.txtgit -c user.name='Subtree Scratch' -c user.email='scratch@example.invalid' \ commit -m "Vendor payload v3"git push origin maincd "$HOME/subtree-scratch/host"git pull --no-rebase --no-edit -s subtree vendor mainif git remote get-url origin >/dev/null 2>&1; then git push origin mainfiPull stdout includes
Merge made by the 'subtree' strategy. Count6, twoparentlines, payload containsvendor third commit. If origin exists,HEADmatchesgit ls-remoteonsubtree-host.
How do you verify the deployment works?
Run this probe. It must finish in under 5 seconds and print VERIFY_OK.
export GH_PROMPT_DISABLED=1 GH_PAGER=cat GIT_TERMINAL_PROMPT=0ROOT="$HOME/subtree-scratch"test -d "$ROOT/vendor/.git" && test -d "$ROOT/host/.git" || { echo VERIFY_FAIL missing_repo; exit 1; }test "$(git -C "$ROOT/vendor" rev-list --count HEAD)" = "3" || { echo VERIFY_FAIL vendor_count; exit 1; }git -C "$ROOT/vendor" ls-tree -r --name-only HEAD | grep -qx 'payload.txt' || { echo VERIFY_FAIL vendor_tree; exit 1; }git -C "$ROOT/host" ls-tree -r --name-only HEAD | grep -qx 'HOST.md' || { echo VERIFY_FAIL host_root; exit 1; }git -C "$ROOT/host" ls-tree -r --name-only HEAD | grep -qx 'vendor/lib.sh' || { echo VERIFY_FAIL missing_prefix_lib; exit 1; }git -C "$ROOT/host" ls-tree -r --name-only HEAD | grep -qx 'vendor/payload.txt' || { echo VERIFY_FAIL missing_prefix_payload; exit 1; }git -C "$ROOT/host" ls-tree -r --name-only HEAD | grep -E '^(lib\.sh|payload\.txt)$' && { echo VERIFY_FAIL leaked_root; exit 1; }git -C "$ROOT/host" log --oneline | grep -q 'Seed vendor lib and payload' || { echo VERIFY_FAIL vendor_history; exit 1; }git -C "$ROOT/host" log --oneline | grep -q 'Seed host tree' || { echo VERIFY_FAIL host_history; exit 1; }test "$(git -C "$ROOT/host" cat-file -p HEAD | awk '/^parent /{c++} END{print c+0}')" = "2" || { echo VERIFY_FAIL parents; exit 1; }grep -qx 'vendor third commit' "$ROOT/host/vendor/payload.txt" || { echo VERIFY_FAIL pull_missing; exit 1; }if git -C "$ROOT/host" remote get-url origin >/dev/null 2>&1; then H_SHA="$(git -C "$ROOT/host" rev-parse HEAD)" test "$H_SHA" = "$(git -C "$ROOT/host" ls-remote origin refs/heads/main | awk '{print $1}')" || { echo VERIFY_FAIL ls_remote; exit 1; } git -C "$ROOT/host" remote get-url origin | grep -q 'subtree-host' || { echo VERIFY_FAIL wrong_origin; exit 1; }fiecho VERIFY_OKgit -C "$ROOT/host" rev-parse HEADExpected: VERIFY_OK and a 40-character SHA (38.8 milliseconds local, 1.2 seconds with ls-remote). Cleanup (--yes required):
export GH_PROMPT_DISABLED=1 GH_PAGER=cat GIT_TERMINAL_PROMPT=0OWNER="$(gh api user --jq .login)"gh repo delete "${OWNER}/subtree-host" --yesgh repo delete "${OWNER}/subtree-vendor" --yesrm -rf "$HOME/subtree-scratch"What are the common production failure modes?
I have seen each of these on a live branch.
fatal: refusing to merge unrelated histories: Git 2.9.0+ needs--allow-unrelated-historieson the-s oursmerge. Exit 128 without it.- Vendor files at the host root: Merge without
-s oursstagespayload.txtnext toHOST.md.git merge --abort, then rerun with-s oursbeforeread-tree. fatal: Need to specify how to reconcile divergent branches: Add--no-rebase --no-edittogit pull -s subtree vendor main. Never--rebase.git subtree addor a subdirectory filter: GitHub usesremote add -f,-s ours, andread-tree --prefix. A subdirectory filter splits a folder out.- Visibility picker,
--web, or a missing vendor remote after clone: Pass--private. After a fresh clone, re-addvendorwithgit remote add -f.
FAQ
Why git merge -s ours before git read-tree --prefix?
Ours records vendor as a parent and keeps the host tree unchanged. read-tree --prefix=vendor/ -u vendor/main then copies that tree under vendor/. Skip -s ours and payload.txt lands next to HOST.md.
Is this the same as git subtree add?
No. git subtree is contrib. GitHub uses git remote add -f, git merge -s ours --no-commit --allow-unrelated-histories, and git read-tree --prefix. Follow that sequence.
How is this different from splitting a subfolder into a new repository? Splitting makes one folder a new root. Subtree merge is the opposite: another repository comes into a prefix. Do not filter a subdirectory here.
Why did git pull -s subtree vendor main fail on Git 2.50?
Git requires an explicit reconcile mode. Pass --no-rebase --no-edit. -s subtree still shifts vendor's root under the prefix.
Do remotes survive a fresh clone of host?
No. Re-add vendor with git remote add -f before the next git pull -s subtree. Never --web.