Back to monorepo orchestration Target affected workspaces Configure turbo pipelines Speed up type-checking

Merging Repositories While Preserving Git History

The quickest way to build a monorepo — copy each repository's files into a subfolder and commit — throws away years of history: git blame points every line at the migration commit, git log on a file starts at the move, and the reasoning captured in old commit messages is lost. Git can do better. With git filter-repo or git subtree, each repository's full history is rewritten into its subdirectory and merged into the monorepo, so git log --follow apps/web/src/App.tsx shows the file's entire life. This guide covers both methods, how to handle tags and ongoing changes during the transition, and how to verify the result.

What "preserving history" means

A good import keeps three things:

  • Commit history per filegit log -- apps/web/src/App.tsx lists every commit that touched the file, including those from before the move.
  • Blamegit blame attributes each line to its original author and commit.
  • Readable top-level history — the monorepo's log shows one merge per imported repository rather than thousands of interleaved commits with confusing paths.

The overall migration plan is covered in Monorepo Migration and Adoption.

Importing a repository with filter-repo Clone the source repository, rewrite every path into the target subdirectory, add the rewritten clone as a remote of the monorepo, and merge it with unrelated histories allowed. fresh clone of acme-web never rewrite your working copy filter-repo --to-subdirectory -filter paths become apps/web/... add as remote of monorepo git remote add web ../acme-web merge --allow-unrelated -histories one merge commit per import
Rewriting paths before merging makes every historical commit appear under the new folder.

Method 1: git filter-repo (recommended)

git filter-repo is the tool the Git project recommends for history rewriting. It rewrites every commit so files appear under a subdirectory, which is exactly what makes blame and per-file logs work after the merge.

# Install once (Python-based)
pip install git-filter-repo

# 1. Fresh clone — filter-repo refuses to run on a non-fresh clone by default
git clone https://git.example.com/acme/acme-web.git /tmp/acme-web
cd /tmp/acme-web

# 2. Move every file in every commit under apps/web/, and prefix tags
git filter-repo --to-subdirectory-filter apps/web --tag-rename '':'web-'

# 3. Merge into the monorepo
cd ~/src/acme
git remote add web /tmp/acme-web
git fetch web --tags
git merge --allow-unrelated-histories --no-ff web/main -m "Import acme-web with history"
git remote remove web

The --tag-rename option prefixes imported tags (v2.3.0 becomes web-v2.3.0) so tags from different repositories do not collide. Adjust or drop it depending on how you want release tags to look in the monorepo.

Method 2: git subtree

git subtree add imports a repository into a prefix without rewriting it first. It is built into most Git installations and supports pulling later changes with git subtree pull, which is useful when the old repository stays active for a while.

cd ~/src/acme
git subtree add --prefix=apps/web https://git.example.com/acme/acme-web.git main
# later, to bring over changes merged in the old repository
git subtree pull --prefix=apps/web https://git.example.com/acme/acme-web.git main

The trade-off is in how history looks afterwards. Because commits are not rewritten, the imported commits still record paths at the old repository root. git log -- apps/web/src/App.tsx may stop at the import commit unless you use git log --follow or subtree-aware tooling, and blame works but can be confusing in some tools. Use --squash only if you deliberately want to drop history.

filter-repo versus subtree for importing repositories Compares git filter-repo and git subtree on per-file history, blame, incremental updates, tags and setup. git filter-repo git subtree Per-file log after import complete may need --follow Blame original authors original authors Bring over later changes re-run on new commits subtree pull Tag handling rename on import manual Tooling needed install filter-repo built in
filter-repo gives the cleanest history; subtree is simpler for repositories that keep receiving changes during the transition.

Handling changes made during the transition

The old repository usually keeps receiving commits between the import and the freeze. Two approaches keep them:

  1. Import early, then re-import the delta. With subtree, git subtree pull brings new commits over. With filter-repo, run the rewrite again on a fresh clone and merge; Git recognises commits already imported (they have identical rewritten content and metadata) and only adds the new ones — as long as the filter-repo options are identical both times.
  2. Freeze, then import once. Announce a short freeze, merge or close open pull requests, import, and make the old repository read-only immediately after.

For long-lived branches in the old repository, import them as branches: after the rewrite, push web/feature-x into the monorepo as import/web/feature-x, so work in progress survives with history intact.

Large files, secrets and other history hazards

Importing history imports everything in it, including things you would rather not bring along. Check each repository before merging it into a shared monorepo that more people can read.

Secrets committed in the past. A token committed and later deleted is still in history. Run a secret scanner over the full history of each repository (gitleaks detect --log-opts="--all" or similar) before importing. If anything turns up, rotate it first, then use filter-repo's --replace-text or path filters on the fresh clone to remove it from the rewritten history, so the monorepo never contains it.

Large binaries. Build artefacts, design files or database dumps committed years ago make every clone of the monorepo slower. git filter-repo --analyze produces a report of the largest paths in history; strip what you do not need with --path ... --invert-paths, or migrate genuine assets to Git LFS before importing.

Generated and vendored code. Old dist/ folders or vendored dependencies checked in before the project used a package manager inflate history without adding value; they can be filtered out of the rewrite the same way.

Author identities. Different repositories may have used different email addresses for the same people. A .mailmap file at the monorepo root unifies them for git log and git shortlog without rewriting anything.

Keeping commit messages meaningful

Imported commit messages refer to paths and issue numbers from the old repository. Two small steps help future readers. Put a note in each import merge commit that names the source repository, its final commit SHA and the filter-repo command used, so anyone reading an old commit knows where it came from. And if the old repository's issue references (#123) would be misinterpreted as monorepo issues, use filter-repo's --message-callback to rewrite them to fully qualified references such as acme/acme-web#123 during the import.

Adjusting the imported project

After the merge, the imported project sits in its folder with its own package.json, lockfile and CI configuration. Clean up in follow-up commits:

  • Delete the imported lockfile (apps/web/package-lock.json) and reinstall from the monorepo root so there is one lockfile.
  • Move CI configuration from apps/web/.github/workflows into the root workflows, adjusting paths.
  • Convert dependencies on sibling packages to the workspace protocol, as covered in Using the workspace: Protocol Correctly.
  • Remove duplicated tooling configuration in favour of the monorepo's shared packages.

Keeping these as separate commits after the import merge makes the history easy to read: one commit brings the code in unchanged, the following commits adapt it.

Verifying the import

# Every historical commit touching a file is visible
git log --oneline -- apps/web/src/App.tsx | wc -l

# Blame attributes lines to original commits, not the import
git blame -L 1,20 apps/web/src/App.tsx

# Tags came across with the chosen prefix
git tag -l 'web-*' | tail -5

# The imported tree matches the source repository at its last commit
diff <(git -C /tmp/acme-web-original ls-files | sort) <(git ls-files apps/web | sed 's|^apps/web/||' | sort)

The last check compares file lists between the source repository's main and the imported folder; any difference means something was lost or added during the import.

Monorepo history after two imports The main branch contains the initial scaffold commit and two import merge commits, each bringing in the full rewritten history of one repository under its folder. main Merge: Import acme-api with history 2nd parent: 1,204 rewritten commits apps/api/** all paths prefixed Merge: Import acme-web with history 2nd parent: 3,587 rewritten commits apps/web/** tags renamed web-v* Scaffold workspace pnpm-workspace.yaml, turbo.json
Each import is one merge commit whose second parent carries the imported repository's full history.

Worked example: four repositories into one

A team imports four repositories — two applications and two libraries — over two weeks. They use filter-repo for all four, importing the libraries first because both applications depend on them. For each import they run the rewrite on a fresh clone, merge, then add follow-up commits that delete the old lockfile, switch internal dependencies to workspace:^ and move CI. One application keeps receiving hotfixes during the transition; the team re-runs the identical filter-repo command after the freeze and merges again, which adds only the six new commits. After the final import, git blame in any file shows original authors and dates going back five years.

Prevention and guardrails

  • Always rewrite a fresh clone, never your working copy of the old repository.
  • Record the exact filter-repo command in the import commit message so the delta import can be repeated identically.
  • Prefix tags to avoid collisions between repositories.
  • Keep adaptation changes out of the import merge, in separate commits.

Frequently Asked Questions

Does importing history make the monorepo clone much larger? It adds the size of each repository's history, which is usually modest for source code. Very large binary histories can be excluded with filter-repo's path filters, or handled with Git LFS before importing.

Can I import only part of a repository? Yes. filter-repo's --path option keeps only matching paths (and their history) before rewriting them into the subdirectory.

What about pull request and issue history? Git history comes along; hosting-platform data such as pull requests and issues does not. Keep the old repository archived for reference, and link to it from the imported folder's README.

Related

Monorepo Migration and Adoption