Back to Resources

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
5 linescompact
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"]
Rendered from Mermaid source with the native ZeroLabs diagram container.

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.

ApproachFiles after the joinHistoryThis recipe
merge -s ours then read-tree --prefix=vendor/Vendor blobs only under vendor/2 parents. git log lists both graphsRequired first join
git pull --no-rebase -s subtree vendor mainUpdates land under vendor/New merge. Count risesRequired later updates
git subtree add --prefix=vendor/Similar tree, different toolContrib git-subtree.sh wrapperDo not run
git filter-repo --subdirectory-filterFolder becomes a new rootDrops other paths. New SHAsOpposite job
git submodulePointer file, not vendor blobsSeparate cloneAbort

-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.

Terminalbash
export GH_PROMPT_DISABLED=1 GH_PAGER=cat GIT_TERMINAL_PROMPT=0git --versiongh --versiongh api user --jq .login

Expected: 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.

  1. Create the private vendor repository with files at the root. Two commits so host git log can show vendor ancestors.

    Terminalbash
    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 --push

    Probe (under 5 seconds): git ls-tree -r --name-only HEAD lists lib.sh and payload.txt. Count 2. Origin contains subtree-vendor.

  2. Create the private host repository with a root file. GitHub starts with one commit so the ours merge has a HEAD.

    Terminalbash
    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 --push

    Probe: ls-tree lists HOST.md only. Count 1. Origin contains subtree-host.

  3. Add vendor as a fetched remote on host. git remote add -f fetches immediately, so you get vendor/main without a separate git fetch.

    Terminalbash
    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 commits and vendor/main. git ls-tree -r --name-only vendor/main lists lib.sh and payload.txt at vendor's own root. Host still has HOST.md only.

  4. Record the ours merge, then read the vendor tree under vendor/. Ours does not change files. --no-commit leaves the merge open so read-tree can stage the prefix. Without --allow-unrelated-histories, Git 2.9.0+ exits 128 on the merge, not on read-tree.

    Terminalbash
    cd "$HOME/subtree-scratch/host"git merge -s ours --no-commit --allow-unrelated-histories vendor/maingit read-tree --prefix=vendor/ -u vendor/main

    git ls-files lists HOST.md, vendor/lib.sh, vendor/payload.txt. If payload.txt is staged at the host root, you omitted -s ours. git merge --abort.

  5. Commit the subtree merge and prove both trees. Full git log lists both graphs. git log -- vendor/ only shows the merge that created those paths.

    Terminalbash
    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_ONLY

    Expected ls-tree: HOST.md, vendor/lib.sh, vendor/payload.txt. Count 4. Two parent lines. PREFIX_ONLY.

  6. Pull later vendor updates with the subtree strategy, then push host only if origin exists. GitHub's git pull -s subtree REMOTE-NAME BRANCH-NAME dies on Git 2.50 with fatal: Need to specify how to reconcile divergent branches. Pass --no-rebase --no-edit.

    Terminalbash
    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 mainfi

    Pull stdout includes Merge made by the 'subtree' strategy. Count 6, two parent lines, payload contains vendor third commit. If origin exists, HEAD matches git ls-remote on subtree-host.

How do you verify the deployment works?

Run this probe. It must finish in under 5 seconds and print VERIFY_OK.

Terminalbash
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 HEAD

Expected: VERIFY_OK and a 40-character SHA (38.8 milliseconds local, 1.2 seconds with ls-remote). Cleanup (--yes required):

Terminalbash
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-histories on the -s ours merge. Exit 128 without it.
  • Vendor files at the host root: Merge without -s ours stages payload.txt next to HOST.md. git merge --abort, then rerun with -s ours before read-tree.
  • fatal: Need to specify how to reconcile divergent branches: Add --no-rebase --no-edit to git pull -s subtree vendor main. Never --rebase.
  • git subtree add or a subdirectory filter: GitHub uses remote add -f, -s ours, and read-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-add vendor with git 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.

Share