Merging two repositories while keeping history Jump to heading
Consolidating two repositories is mostly an exercise in avoiding collisions. Both have a README, both have a .gitignore, both may have a src/ directory, and a naive merge presents every one of those as a conflict between histories that share no ancestor. Move each side into its own subdirectory first and the merge becomes trivial — and, more importantly, every commit keeps its content, its author and its date. This recipe does that, within repository migration and consolidation.
When to use this approach Jump to heading
- Two repositories now change together often enough that the split costs more than it saves.
- You need
git logandgit blameto keep working across the combined codebase. - The repositories have no shared history — if they do, an ordinary merge is enough.
- You can afford a short freeze on both sides.
- If you only need the current files, copying them in one commit is simpler; history is the whole reason for this procedure.
Step 1 — Mirror both sources Jump to heading
mkdir -p /tmp/consolidate && cd /tmp/consolidate
git clone --mirror https://github.com/acme/billing.git backup-billing.git
git clone --mirror https://github.com/acme/invoicing.git backup-invoicing.git # Working copies, made from the backups so the originals are never touched
git clone --no-local backup-billing.git billing
git clone --no-local backup-invoicing.git invoicing # Verification: record what must survive
for r in billing invoicing; do
echo "$r $(git -C $r rev-list --count --all) commits"
git -C $r log --format='%T' --all | sort -u > "/tmp/$r-trees.txt"
done Step 2 — Move each side into its own subdirectory Jump to heading
Rewriting paths before the merge is what removes the collisions. git-filter-repo does it across all history in one pass.
(cd billing && git filter-repo --to-subdirectory-filter services/billing)
(cd invoicing && git filter-repo --to-subdirectory-filter services/invoicing) # Verification: every path in history now lives under the new prefix
git -C billing log --name-only --format='' --all | grep -v '^$' | grep -cv '^services/billing/' That count should be zero. Anything else means a path escaped the rewrite, which is almost always a file outside the working tree such as a submodule pointer.
Step 3 — Merge the unrelated histories Jump to heading
git init combined && cd combined
git commit --allow-empty -m 'chore: initialise the combined repository'
for r in billing invoicing; do
git remote add "$r" "../$r"
git fetch "$r"
git merge --allow-unrelated-histories -m "chore: import $r with full history" "$r/main"
git remote remove "$r"
done # Verification: commit count is the sum, plus the merges and the empty root
git rev-list --count --all
git log --format='%H %P' | awk 'NF==3 {m++} END {print m " merge commit(s)"}' --allow-unrelated-histories exists to make you think about exactly this situation. Git refuses by default because merging two histories with no common ancestor is usually a mistake — here it is the intent.
Step 4 — Verify nothing was lost Jump to heading
# Every tree hash from each source must still be reachable
git log --format='%T' --all | sort -u > /tmp/combined-trees.txt
comm -13 /tmp/combined-trees.txt /tmp/billing-trees.txt | head
comm -13 /tmp/combined-trees.txt /tmp/invoicing-trees.txt | head # And the working tree must contain both codebases intact
diff -r ../billing/services/billing services/billing && echo "billing intact"
diff -r ../invoicing/services/invoicing services/invoicing && echo "invoicing intact" # Blame should reach back into the original history
git log --follow --oneline -- services/billing/src/ledger.ts | tail -3 Step 5 — Resolve what subdirectories could not separate Jump to heading
Paths are separated; tags, configuration and tooling are not.
# Tags from both sources may collide — namespace them during the fetch
git fetch billing 'refs/tags/*:refs/tags/billing/*'
git fetch invoicing 'refs/tags/*:refs/tags/invoicing/*' # Repository-level configuration must be merged by hand, deliberately
cat services/billing/.gitignore services/invoicing/.gitignore | sort -u > .gitignore
git rm --cached services/*/.gitignore 2>/dev/null || true # Verification: no duplicate tag names, and one ignore file at the root
git tag | sort | uniq -d
git ls-files '*.gitignore' SAFETY WARNING — do not delete the source repositories once the merge looks right. Archive them read-only. A consolidation surfaces problems for weeks: a branch nobody mentioned, a tag referenced by a deployment, a pipeline reading from the old location. Every one of those is a lookup while the sources exist and an incident after they are gone.
Ownership rules deserve attention too, since the combined repository now has two areas with different owners — the pattern in path-based CODEOWNERS in a monorepo applies directly.
Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Will git blame still work across the merge? Jump to heading
Yes for the files themselves, because their content and commits are preserved. Blame follows the rename introduced by the subdirectory rewrite automatically in most cases, and git log --follow makes it explicit where it does not. What changes is that the paths in old commit messages and review links no longer match the new layout.
Should the combined repository keep both default branches? Jump to heading
No — pick one and merge the other into it, which is what the procedure above does. Two long-lived default branches in one repository is a configuration nothing supports well, and it produces a permanent question about where new work belongs.
What if the two repositories share some identical files? Jump to heading
Identical files are not a problem once they live in different subdirectories; they simply exist twice. Deduplicating them is a separate refactor, and doing it as part of the merge makes the migration impossible to verify — do the merge, prove it faithful, then refactor.
Related Jump to heading
- Repository Migration & Consolidation — the parent topic and the four migration shapes.
- Splitting a Directory Into Its Own Repository — the inverse operation.
- Converting Many Repos Into One Monorepo — retiring the sources once the merge has settled.