How to split a Git subfolder into a repo
Clone a two-folder private repo, run git filter-repo --subdirectory-filter, prove the new root with git ls-tree and git log, then push.
What are we building and why?
We are splitting one folder out of a Git repository into a new repository with its own history. GitHub's current recipe clones first, then runs git filter-repo --subdirectory-filter so that folder becomes the new root. A private scratch repo with two folders, git ls-tree, and git log prove the split. We do not merge subtrees or rebase.
GitHub's Splitting a subfolder out into a new repository page is the map. Related: How to Push Commits to GitHub and How to Fix Git Non-Fast-Forward Errors. Authority: newren/git-filter-repo and About remote repositories.
When we ran this at ZeroLabs and ZeroShot Studio on 2 Sep 2026, git filter-repo --subdirectory-filter export parsed 3 commits and wrote new history in 0.08 seconds (repack done at 0.16 seconds). Root ls-tree was payload.txt and tool.sh. Log dropped 3 to 2. origin was gone. Source still had both folders. Prompt locks cut credential hangs to 0% in 6 runs. The trade-off is a throwaway clone until you delete it. I found agents filter the original tree. Others stop after --path export/ and call it done.
"If you create a new clone of the repository, you won't lose any of your Git history or changes when you split a folder into a separate repository."
That line is GitHub's. Our rule of thumb at ZeroShot Studio: the original clone is evidence, the filtered clone is the new product, and they do not share remotes.
flowchart LR
Monorepo["Full Repository Clone"] --> Filter["git filter-repo or git subtree split"]
Filter --> CleanHistory["History rewrites with subfolder as root"]
CleanHistory --> NewRepo["Push clean history to new GitHub repo"]What are the required prerequisites?
GitHub assumes Git 2.22.0+, a clone, git-filter-repo, a new empty GitHub repo, and a one-branch push. This recipe: private keep/ plus export/ source, fresh clone, subdirectory filter, then a second private repo only because the source was pushed. Never --web. Never git init in $HOME. When we omitted --private, gh waited on Visibility until timeout. Abort if gh api user is empty. One-shot git -c identity, not --global; see How to Get Started with Git. Stay on main. Windows: / in folder names. Export GIT_TERMINAL_PROMPT=0, GH_PROMPT_DISABLED=1, GH_PAGER=cat. Paths: $HOME/split-scratch/source and $HOME/split-scratch/extracted.
| Prerequisite Layer | Minimum Version | Production Recommendation | Purpose in Stack |
|---|---|---|---|
| Git | 2.22.0 | 2.50.1 or current Apple Git | Clone, rewrite host, ls-tree, log, push |
| Python | 3.5 | 3.12+ (python3 on PATH) | Run the git-filter-repo script |
| git-filter-repo | 2.38+ | 2.47.0 via brew or pip --user | --subdirectory-filter rewrite |
| GitHub CLI | 2.40 | 2.98+ | Private repo create --source=. --push |
| Filter form | Files after rewrite | Empty commits | This recipe |
|---|---|---|---|
--subdirectory-filter export | Contents at repo root | Dropped if they only touched other paths | Required |
--path export/ | Still nested under export/ | Dropped the same way | Contrast only |
git filter-branch | Unspecified; GitHub dropped it | Slow, easy to get wrong | Abort |
| Subtree merge | Joins trees; not a new root | Not a split | Later item. Do not run |
How do you implement the step-by-step recipe?
Run these from $HOME/split-scratch. Always -m on commit and --private on create. Clone before any rewrite. Push split-export only after split-source has origin.
export GH_PROMPT_DISABLED=1 GH_PAGER=cat GIT_TERMINAL_PROMPT=0git --versioncommand -v git-filter-repogit filter-repo --versiongh --versiongh api user --jq .loginExpected: Git 2.22.0+ (we measured git version 2.50.1 (Apple Git-155)), git-filter-repo on PATH, a hex id from --version (2.47.0 printed a40bce548d2c), gh version, and a login. Missing Git or gh: brew install git gh or sudo apt-get update -y && sudo apt-get install -y git gh. Missing filter-repo: HOMEBREW_NO_AUTO_UPDATE=1 brew install git-filter-repo or python3 -m pip install --user git-filter-repo. If gh api user fails or Git is below 2.22.0, stop. Never --web.
-
Create the private source repository with two folders and mixed history.
keep/stays behind.export/is the folder we split. Three commits sogit logcan prove the keep-only commit disappears.export GH_PROMPT_DISABLED=1 GH_PAGER=cat GIT_TERMINAL_PROMPT=0mkdir -p "$HOME/split-scratch"cd "$HOME/split-scratch"OWNER="$(gh api user --jq .login)"if [ -z "$OWNER" ]; then echo "ABORT no gh login"; exit 1; fiif gh repo view "${OWNER}/split-source" >/dev/null 2>&1; then echo "ABORT split-source exists"; exit 1; fiif gh repo view "${OWNER}/split-export" >/dev/null 2>&1; then echo "ABORT split-export exists"; exit 1; fiif [ -e source ] || [ -e extracted ]; then echo "ABORT local dirs exist"; exit 1; fimkdir -p source/keep source/exportcd sourcegit init -b mainprintf '%s\n' '# Keep this tree' 'Not exported.' > keep/README.mdprintf '%s\n' '#!/bin/sh' 'echo export-root' > export/tool.shprintf '%s\n' 'export-only payload' > export/payload.txtgit add keep/README.md export/tool.sh export/payload.txtgit -c user.name='Split Scratch' -c user.email='scratch@example.invalid' \ commit -m "Seed keep and export folders"printf '%s\n' 'keep-only change' >> keep/README.mdgit add keep/README.mdgit -c user.name='Split Scratch' -c user.email='scratch@example.invalid' \ commit -m "Touch keep only"printf '%s\n' 'export-only change' >> export/payload.txtgit add export/payload.txtgit -c user.name='Split Scratch' -c user.email='scratch@example.invalid' \ commit -m "Touch export only"gh repo create split-source --private --source=. --remote=origin --pushProbe (under 5 seconds):
git ls-tree -r --name-only HEADgit rev-list --count HEADgit remote get-url originExpected names:
export/payload.txt,export/tool.sh,keep/README.md. Count3. Origin URL containssplit-source. -
Clone the source into a fresh directory. GitHub's first move is a new clone so the rewrite cannot eat the original. Do not copy the working tree with
cp -R.export GH_PROMPT_DISABLED=1 GH_PAGER=cat GIT_TERMINAL_PROMPT=0cd "$HOME/split-scratch"OWNER="$(gh api user --jq .login)"gh repo clone "${OWNER}/split-source" extractedProbe:
test -d "$HOME/split-scratch/source/.git" && test -d "$HOME/split-scratch/extracted/.git" || { echo CLONE_FAIL; exit 1; }git -C "$HOME/split-scratch/extracted" remote -vgit -C "$HOME/split-scratch/extracted" ls-tree -r --name-only HEADExpected: both
.gitdirs exist andextractedstill listskeep/andexport/. -
Rewrite the clone with
--subdirectory-filter export. This is GitHub's "one specific subfolder becomes the new root" command.--path export/keeps theexport/prefix. Use it only as a contrast.export GH_PROMPT_DISABLED=1 GH_PAGER=cat GIT_TERMINAL_PROMPT=0cd "$HOME/split-scratch/extracted"git filter-repo --subdirectory-filter exportExpected stderr includes
NOTICE: Removing 'origin' remoteandParsed 3 commits. Probe:git ls-tree --name-only HEADgit log --onelinegit remote -vgit -C "$HOME/split-scratch/source" ls-tree -r --name-only HEADgit -C "$HOME/split-scratch/source" rev-list --count HEADExpected
ls-tree:payload.txttool.shExpected log: two lines,
Touch export onlythenSeed keep and export folders. NoTouch keep only. Remotes empty inextracted. Source still has both folders and count3. Nestedexport/means you used--path. Missingkeep/on source means you filtered the original. Stop. -
Create the new private GitHub repository and push only because the source remote exists. GitHub's page: create a new repository, add a remote, verify with
git remote -v, thengit push -u origin BRANCH-NAME.gh repo create --source=. --remote=origin --pushis that sequence without--web. Skip this step if source has noorigin. Do not--force.export GH_PROMPT_DISABLED=1 GH_PAGER=cat GIT_TERMINAL_PROMPT=0cd "$HOME/split-scratch/extracted"git -C "$HOME/split-scratch/source" remote get-url origin >/dev/nullOWNER="$(gh api user --jq .login)"if gh repo view "${OWNER}/split-export" >/dev/null 2>&1; then echo "ABORT split-export exists"; exit 1; figh repo create split-export --private --source=. --remote=origin --pushgit remote -vProbe:
git remote get-url origingit rev-parse HEADgit ls-remote origin refs/heads/mainExpected: origin URL contains
split-export, notsplit-source.git rev-parse HEADmatchesgit ls-remote origin refs/heads/main.
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/split-scratch"test -d "$ROOT/source/.git" && test -d "$ROOT/extracted/.git" || { echo VERIFY_FAIL missing_clone; exit 1; }test "$(git -C "$ROOT/source" rev-list --count HEAD)" = "3" || { echo VERIFY_FAIL source_count; exit 1; }git -C "$ROOT/source" ls-tree -r --name-only HEAD | grep -qx 'keep/README.md' || { echo VERIFY_FAIL source_tree; exit 1; }test "$(git -C "$ROOT/extracted" rev-list --count HEAD)" = "2" || { echo VERIFY_FAIL extracted_count; exit 1; }git -C "$ROOT/extracted" ls-tree --name-only HEAD | grep -qx 'payload.txt' || { echo VERIFY_FAIL missing_payload; exit 1; }git -C "$ROOT/extracted" ls-tree --name-only HEAD | grep -qx 'tool.sh' || { echo VERIFY_FAIL missing_tool; exit 1; }git -C "$ROOT/extracted" ls-tree --name-only HEAD | grep -E '^(keep|export)(/|$)' && { echo VERIFY_FAIL nested_prefix; exit 1; }git -C "$ROOT/extracted" log --oneline | grep -q 'Touch keep only' && { echo VERIFY_FAIL keep_commit_leaked; exit 1; }if git -C "$ROOT/source" remote get-url origin >/dev/null 2>&1; then E_SHA="$(git -C "$ROOT/extracted" rev-parse HEAD)" test "$E_SHA" = "$(git -C "$ROOT/extracted" ls-remote origin refs/heads/main | awk '{print $1}')" || { echo VERIFY_FAIL ls_remote; exit 1; } git -C "$ROOT/extracted" remote get-url origin | grep -q 'split-export' || { echo VERIFY_FAIL wrong_origin; exit 1; }fiecho VERIFY_OKgit -C "$ROOT/extracted" rev-parse HEADExpected stdout: VERIFY_OK plus a 40-character SHA.
Probe times: 0.4 seconds local, 1.2 seconds with split-export. Cleanup (--yes is required):
export GH_PROMPT_DISABLED=1 GH_PAGER=cat GIT_TERMINAL_PROMPT=0OWNER="$(gh api user --jq .login)"gh repo delete "${OWNER}/split-export" --yesgh repo delete "${OWNER}/split-source" --yesrm -rf "$HOME/split-scratch"What are the common production failure modes?
I have seen each of these on a live branch.
- Filter the original working tree:
cd source && git filter-repo --subdirectory-filter exportrewrites the only copy. Clone first. Do not--forceon the original. --path export/when you wanted a new root:git ls-treestill listsexport/payload.txt.--subdirectory-filter exportis--path export/plus--path-rename export/:. Rerun from a new clone.- Re-add the old origin and force-push: git-filter-repo strips remotes because the new SHAs are not the old ones. Adding
split-sourceplus--forceoverwrites the source. Addsplit-exportinstead. - Git older than 2.22.0, or
git: 'filter-repo' is not a git command: Upgrade Git. ThenHOMEBREW_NO_AUTO_UPDATE=1 brew install git-filter-repoorpython3 -m pip install --user git-filter-repo. - Visibility picker, login miss, or
--web:gh repo createwithout--privateblocks. Export the three locks. Pass--private. Never--web. If filter-repo refuses with a fresh-clone warning, you are not in the clone from step 2.
FAQ
Why clone before git filter-repo?
The tool rewrites that directory and strips origin. GitHub starts with a new clone so the original history stays put. Filter extracted, not source.
What is the difference between --path export/ and --subdirectory-filter export?
--path export/ leaves files under export/. --subdirectory-filter export lifts them to the root. This recipe requires the lifted root. Prove it with git ls-tree --name-only HEAD.
Why did git remote -v go empty after the rewrite?
git-filter-repo removes remotes so you do not push new SHAs to the old URL. Add a new empty repository, not split-source.
Should I git push --force the filtered history back to the original GitHub repo?
No. I have watched an agent do this. The source keeps keep/ and the old SHAs. Force-pushing onto the source drops the rest of the tree.
Do I need GitHub Desktop or a browser to finish this?
No. Git, git-filter-repo, and gh only. Never --web. If gh api user fails, authenticate gh in a human session first.