Back to Resources

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

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 LayerMinimum VersionProduction RecommendationPurpose in Stack
Git2.22.02.50.1 or current Apple GitClone, rewrite host, ls-tree, log, push
Python3.53.12+ (python3 on PATH)Run the git-filter-repo script
git-filter-repo2.38+2.47.0 via brew or pip --user--subdirectory-filter rewrite
GitHub CLI2.402.98+Private repo create --source=. --push
Filter formFiles after rewriteEmpty commitsThis recipe
--subdirectory-filter exportContents at repo rootDropped if they only touched other pathsRequired
--path export/Still nested under export/Dropped the same wayContrast only
git filter-branchUnspecified; GitHub dropped itSlow, easy to get wrongAbort
Subtree mergeJoins trees; not a new rootNot a splitLater 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.

Terminalbash
export GH_PROMPT_DISABLED=1 GH_PAGER=cat GIT_TERMINAL_PROMPT=0git --versioncommand -v git-filter-repogit filter-repo --versiongh --versiongh api user --jq .login

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

  1. Create the private source repository with two folders and mixed history. keep/ stays behind. export/ is the folder we split. Three commits so git log can prove the keep-only commit disappears.

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

    Probe (under 5 seconds):

    Terminalbash
    git ls-tree -r --name-only HEADgit rev-list --count HEADgit remote get-url origin

    Expected names: export/payload.txt, export/tool.sh, keep/README.md. Count 3. Origin URL contains split-source.

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

    Terminalbash
    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" extracted

    Probe:

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

    Expected: both .git dirs exist and extracted still lists keep/ and export/.

  3. Rewrite the clone with --subdirectory-filter export. This is GitHub's "one specific subfolder becomes the new root" command. --path export/ keeps the export/ prefix. Use it only as a contrast.

    Terminalbash
    export GH_PROMPT_DISABLED=1 GH_PAGER=cat GIT_TERMINAL_PROMPT=0cd "$HOME/split-scratch/extracted"git filter-repo --subdirectory-filter export

    Expected stderr includes NOTICE: Removing 'origin' remote and Parsed 3 commits. Probe:

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

    Expected ls-tree:

    text
    payload.txttool.sh

    Expected log: two lines, Touch export only then Seed keep and export folders. No Touch keep only. Remotes empty in extracted. Source still has both folders and count 3. Nested export/ means you used --path. Missing keep/ on source means you filtered the original. Stop.

  4. 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, then git push -u origin BRANCH-NAME. gh repo create --source=. --remote=origin --push is that sequence without --web. Skip this step if source has no origin. Do not --force.

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

    Probe:

    Terminalbash
    git remote get-url origingit rev-parse HEADgit ls-remote origin refs/heads/main

    Expected: origin URL contains split-export, not split-source. git rev-parse HEAD matches git 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.

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

Expected stdout: VERIFY_OK plus a 40-character SHA.

Probe times: 0.4 seconds local, 1.2 seconds with split-export. Cleanup (--yes is required):

Terminalbash
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 export rewrites the only copy. Clone first. Do not --force on the original.
  • --path export/ when you wanted a new root: git ls-tree still lists export/payload.txt. --subdirectory-filter export is --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-source plus --force overwrites the source. Add split-export instead.
  • Git older than 2.22.0, or git: 'filter-repo' is not a git command: Upgrade Git. Then HOMEBREW_NO_AUTO_UPDATE=1 brew install git-filter-repo or python3 -m pip install --user git-filter-repo.
  • Visibility picker, login miss, or --web: gh repo create without --private blocks. 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.

Share