Splitting a directory into its own repository Jump to heading

A package inside a monorepo eventually earns independence: it is consumed by other teams, it releases on its own cadence, or its ownership has moved. Copying the files into a new repository takes five minutes and throws away every commit message, every author attribution and every explanation of why the code looks the way it does. Extracting it with its history takes slightly longer and keeps all of that. This recipe does the extraction using the rewriting tools introduced in History Rewriting & Recovery.

When to use this approach Jump to heading

  • A directory has become a distinct product with its own release cycle and its own consumers.
  • Ownership has moved to a team that should not need access to the rest of the repository.
  • The package will be published, and its history contains attribution worth preserving.
  • The monorepo’s branch topology no longer matches how this code is developed.
  • If the directory is still coupled to the rest of the repository — shared build config, cross-imports, a common test harness — extraction converts every internal refactor into a cross-repository migration. Fix the coupling first, or keep it where it is.

Step 1 — Confirm the directory is genuinely separable Jump to heading

# What does this directory import from outside itself?
git grep -nE "from ['\"](\.\./)+" -- services/billing | head -20

# How often do commits touch both this directory and the rest?
git log --format='%H' -- services/billing | while read -r sha; do
  git show --name-only --format= "$sha" | grep -qv '^services/billing/' && echo "$sha"
done | wc -l

What changed: nothing — but that second number is the decision. If most commits touching the directory also touch files outside it, the code is not separable yet, and extraction will make every future change a coordinated two-repository release.

Separable, or merely in its own folder?On the left, the directory depends only on published packages and nothing in the repository reaches into it, so extraction is clean. On the right, the directory imports shared internal modules and other code imports it back, so extraction turns every refactor into a two-repository migration.separable — extract itservices/billingthe candidatepublished depsregistry packagesthe rest of the repositoryno edges in either directionentangled — fix this firstservices/billingthe candidatelibs/sharedinternal, unpublishedimports both waysevery refactor becomes a two-repo release

Step 2 — Extract it with a subdirectory filter Jump to heading

Work in a throwaway clone. The original repository is not touched at any point in this recipe.

# 1. A fresh clone to rewrite — never the repository people are working in
git clone https://example.com/org/monorepo.git billing-extract
cd billing-extract

# 2. Keep only this subdirectory, and promote it to the repository root
git filter-repo --subdirectory-filter services/billing

What changed: every commit that touched services/billing survives with its message, author and date; its tree now starts at the repository root instead of three directories down. Commits that never touched the directory are dropped because they would be empty.

# The tree should now start at the package root
ls
git log --oneline | head -5
git log --oneline | wc -l      # far fewer commits than the monorepo had

If the path moved during its life — packages/billing before a reorganisation, services/billing after — a plain subdirectory filter loses the earlier history. Name both paths instead:

git filter-repo \
  --path packages/billing --path services/billing \
  --path-rename packages/billing/: \
  --path-rename services/billing/:

Step 3 — Verify the extracted history Jump to heading

What the extraction keeps and what it dropsThe monorepo history contains commits touching many areas. After extraction only the commits that touched the billing directory remain, in the same order with the same messages and authors, and their trees are rooted at the package directory rather than nested inside it.monorepo historywebbillinfrabilldocsbillwebgreen = touched services/billingextracted historybillbillbillmessages, authors and dates preservedSHAs are all newtrees re-rooted at the package
# 1. Only the package's files are present, at the root
git ls-files | head -20

# 2. Attribution survived
git log --format='%an' | sort | uniq -c | sort -rn | head

# 3. The oldest commit is the package's real beginning, not the monorepo's
git log --reverse --oneline | head -3

# 4. The current tree matches the monorepo's copy exactly
diff -r . ../monorepo/services/billing --exclude=.git && echo "trees identical"

Check 4 is the one that catches a mistaken filter. If the trees differ, the path list was wrong and the extraction should be redone rather than patched.

SAFETY WARNING — every SHA in the extracted repository is new, so tickets, changelog entries and deployment records that reference monorepo SHAs will not resolve there. If those references matter, keep the .git/filter-repo/commit-map file that the tool writes: it maps old SHAs to new ones and cannot be reconstructed later.

Step 4 — Publish the new repository Jump to heading

# filter-repo removes the old remote deliberately; point at the new one
git remote add origin https://example.com/org/billing.git
git push -u origin --all
git push origin --tags
# Confirm the published repository is complete
git ls-remote --heads origin
git ls-remote --tags origin | wc -l

Then bring the surroundings with it: the package’s CI workflow, its CODEOWNERS entry, branch protection, and its release configuration. A repository that arrives without its guardrails spends its first month accumulating exactly the problems the monorepo had solved.

Step 5 — Decide what happens to the original path Jump to heading

Three options, and only two are acceptable.

# Recommended: delete it in an ordinary commit once consumers have switched
git rm -r services/billing
git commit -m "chore: move billing to its own repository

Billing now lives at https://example.com/org/billing.
History was extracted with git filter-repo; the code here is
retained in history for reference but is no longer built."

What changed: the monorepo stops building the package. Every existing SHA remains valid, because a deletion is a normal commit and not a rewrite — which is why this is preferable to purging the path from history.

# Verify nothing still references the removed path
git grep -rn 'services/billing' -- ':!*.md' | head
What to do with the original pathDeleting the path in an ordinary commit keeps every existing SHA valid and ends the ambiguity. Making it read-only for a fixed window is acceptable while consumers migrate. Leaving both copies writable is the failure case, because the two diverge and reconciling them costs more than either migration.delete itgit rm -r services/billingan ordinary commit —not a rewriteevery existing SHA stays validold content readable in historyrecommendedfreeze itpre-receive rejects the pathread-only while consumersswitch overneeds a fixed end date,written down, with an owneracceptable, time-boxedleave both writable"just for a transition period"a fix lands in one copywithin daysreconciling them costs morethan either migration didthe failure case

The option to avoid is leaving both copies live “for a transition period”. Two writable copies diverge within days, and the merge that reconciles them is worse than either migration. If a transition period is genuinely needed, make the monorepo copy read-only — enforce it with a server-side rule that rejects pushes touching that path — and give the period a fixed end date.

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Does the extracted repository keep the original commit SHAs? Jump to heading

No. Every commit is rewritten to contain only the extracted subtree, so every SHA differs from the original. Commit messages, authors and dates are preserved, so blame and chronology still work, but a SHA referenced in a ticket or changelog will not resolve in the new repository. Record the mapping filter-repo produces if those references matter.

What happens to commits that never touched the directory? Jump to heading

They are dropped, because after filtering they would be empty. This is what makes the extracted history readable: it contains only the work that actually touched the extracted code. A merge commit whose branches both become empty is pruned along the same lines, so the resulting graph is simpler than the original.

Should the original directory be deleted afterwards? Jump to heading

Delete it in a normal commit — not a rewrite — once the new repository is authoritative and consumers have switched. A plain deletion keeps every existing SHA valid, so nothing else breaks, and the old content stays readable in history for anyone tracing a change. Leaving both copies live is the outcome to avoid, because divergence starts within days.