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 log and git blame to 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.

Two roots, one repositoryEach source history is rewritten so all its paths live under a distinct subdirectory, then both are merged into a repository with two root commits. Every original commit survives with its content, author and date intact.both histories survive under one tipbillingB1B2B3invoicingI1I2combinedM1M2M1 is the merge with two unrelated parents — the only unusual commit in the result

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
Why the subdirectory rewrite has to come firstMerging first produces a conflict for every path the two repositories share, across two histories with no common ancestor, and resolving those by hand discards content. Rewriting paths first means the merge touches no shared path at all.Two repositoriesREADME, src/, configMerge firstconflict per shared pathno common ancestorRewrite firstservices/a, services/bMerge cleanlyno overlapping pathsthere is no correct manual resolution for a conflict between unrelated histories What the combined repository inheritsCommits and tags add up across both sources. Tag names collide often enough to need namespacing, and repository-level configuration files exist twice and must be merged deliberately rather than by the merge algorithm.objects inherited from each sourcebilling commits1840invoicing commits970tags, both sources64colliding config files7only the bottom bar needs a human decision — the rest is arithmetic

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.