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.
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
# 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-mapfile 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 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.
Related Jump to heading
- History Rewriting & Recovery — the parent guide: choosing a tool, taking a backup, and coordinating a cutover.
- Monorepo Branch Topology — the structure this extraction is a retreat from, and when keeping the package in place is the better answer.
- Removing a Leaked Secret from Git History — the same tool used for the other common rewrite, with a different verification burden.