Merging two repos into a monorepo: what git log, blame and --follow see

git subtree add versus filter-repo for moving repos into subfolders, measured: which history commands still work, and what happens to commit ids.

· Published · 7 min read · About the project →

Merging a frontend repository and a backend repository into one is a few commands. What decides whether the history survives is not the merge itself but which git tools can still see it afterwards. This post shows the two ways I know to keep history when moving repositories into subfolders, git subtree add and rewriting paths with git filter-repo first, and measures what git log, --follow and git blame actually show after each.

The occasion was the hospital procurement system: its API and its web app started in 2024 as two repositories, and in March 2026 I moved them into one, as backend/ and frontend/. The measurements below come from a small reproducible experiment with git 2.54, because a real repository's history is too tangled to show the behavior cleanly.

The subtree way: two commands

Start with a repository that has at least one commit, then add each old repository under its prefix:

git init monorepo && cd monorepo
git commit --allow-empty -m "init repo"
git subtree add --prefix=backend  https://github.com/you/api.git main
git subtree add --prefix=frontend https://github.com/you/web.git main

git subtree add fetches the other repository and creates a merge commit whose second parent is that repository's branch tip, with every file moved under the prefix. In the hospital repository the result looks like this:

Add 'backend/' from commit 'c9d7e0b7903e45961b1fca01480e288dbca23ce9'

git-subtree-dir: backend
git-subtree-mainline: ec5a5e1f40d81020ea32bf488bf581238aec05d1
git-subtree-split: c9d7e0b7903e45961b1fca01480e288dbca23ce9

Every old commit is still there, with its original id. The trailers record which commit was imported, which is what later subtree pull and subtree split use. In the experiment, the graph is exactly what you would hope for:

* 9d4c947 after move
*   832eec4 Add 'frontend/' from commit 'c66e091...'
|\
| * c66e091 web: first
*   99d6a46 Add 'backend/' from commit '7663a77...'
|\
| * 7663a77 api: second
| * 7e72647 api: first
* d3d8f4e mono: init

Do not squash if you want the history

git subtree add has a --squash option. With it, the imported history becomes a single commit:

*   6ef6f4f Merge commit 'f55895c...' as 'backend'
|\
| * f55895c Squashed 'backend/' content from commit 7663a77
* 17ea334 init

That is the right choice for vendoring a library you only want a snapshot of. For moving your own project, it throws away exactly the thing the move was meant to keep. The hospital repository was added without --squash, which is why it has more than 500 commits going back to 2024 rather than a handful from 2026.

What git log shows after a subtree add

Here is where the surprise is. After the merge, I changed backend/src/server.ts once more and asked git for that file's history in the obvious ways:

Command Commits shown
git log -- backend/src/server.ts the new commit and the subtree merge
git log --follow -- backend/src/server.ts the new commit only
git blame backend/src/server.ts every line attributed to its original commit, under its old path src/server.ts
git log -- src/server.ts nothing
git log -- backend/src/server.ts src/server.ts all four, old and new
git log --full-history -- src/server.ts the merge and the two old commits

git blame gets it right; git log with a path does not. The old commits changed src/server.ts, not backend/src/server.ts, and a path filter matches paths literally. Asking for the old path alone finds nothing either: at the merge, the main line has no src/server.ts, so git's default history simplification follows the first parent and prunes the imported side. --full-history turns that off. --follow did no better in this experiment, because the file has no earlier name on the main line of history; the rename happened inside the merge.

Commit graph after git subtree add: the main line has mono: init, the subtree merge and after move; the merge's second parent is the imported side with api: first and api: second, which changed src/server.ts. git log -- backend/src/server.ts finds only after move and the merge; git blame, or git log with both paths, also reaches the imported commits.after movechanges backend/src/server.tsAdd 'backend/' (subtree merge)moves the files under backend/api: secondchanges src/server.tsapi: firstchanges src/server.tsmono: initmain line: no src/server.tsgit log -- backend/src/server.tsfinds only these twogit blame, or log with both pathsalso reaches the imported commits
The imported commits changed the old path, and the main line never had it, so a path filter on the new path stops at the merge.

In practice that means:

  • To see who wrote a line and why, git blame works as before, and shows the old path next to each old commit.
  • To see a file's full log, pass both paths: git log -- backend/src/x.ts src/x.ts.
  • To browse only the old project, log its side of the merge: git log 99d6a46^2 -- src/server.ts.

In the hospital repository, git blame backend/src/server.ts still reaches lines written in March 2024.

The other way: rewrite paths, then merge

The alternative is to make the old history look as if it had always lived in the subfolder, then merge that. git-filter-repo does the rewrite in one option:

git clone https://github.com/you/api.git api-moved
cd api-moved && git filter-repo --to-subdirectory-filter backend && cd ..

cd monorepo
git remote add api ../api-moved && git fetch api
git merge --allow-unrelated-histories api/main

Now every old commit touches backend/src/server.ts, so the plain commands work:

Command Commits shown
git log -- backend/src/server.ts all three
git log --follow -- backend/src/server.ts all three

The price is the commit ids. Rewriting a commit's tree gives it a new id, so 7663a77 in the original repository became 782a497 in the experiment. Any link to an old commit, in an issue, a pull request, a changelog or a commit message ("reverts 7663a77"), now points at something that does not exist in the new repository.

Which one to pick

git subtree add filter-repo, then merge
Old commit ids Kept Rewritten
git log -- path Stops at the merge Full history
git log --follow Stops at the merge Full history
git blame Full history Full history
Way back out git subtree split reproduces the old ids Another rewrite
Extra tools None, ships with git git-filter-repo

The hospital system uses subtree. What that buys is twofold. Old commit ids stay valid, so anything that mentions one, an issue, a pull request or another commit message, still points at it. And the move stays reversible: in the experiment, git subtree split --prefix=backend produced a branch whose old commits had exactly their original ids, 7663a77 and 7e72647, with the new commit on top. If the backend ever needs its own repository again, that is one command.

If nobody will ever look up an old commit by id, the filter-repo way gives a history that every tool understands without extra arguments. It is the same tool, used in the other direction, that keeps a fork of a GPL library in sync with its upstream.

What else changes when paths move

History is not the only thing that assumed the old root. After the move, everything that referred to paths from the repository root needs the prefix:

  • CI workflows: working directories, cache keys, path filters for which job runs on which change.
  • Docker builds: the build context and COPY paths.
  • Deployment settings that point at a directory, such as a hosting provider's root directory for the frontend.
  • Editor and tool configuration at the root: lint configs, .gitignore patterns, pre-commit hooks.

None of these fail at merge time. They fail at the first build after it, so it is worth doing the move on a branch and letting CI run before merging.

Summary

Goal What to do
Keep history and old commit ids git subtree add without --squash
See who wrote a line after the move git blame, which follows the old path
See a file's full log after a subtree add Pass both paths, or log the merge's second parent
Plain git log and --follow everywhere Rewrite with filter-repo --to-subdirectory-filter, accept new ids
Keep a way back out git subtree split --prefix=...
Avoid broken builds Move on a branch, update CI, Docker and deploy paths, let CI run

If you have merged repositories with a different approach, I'd love to compare notes on how your history held up. My email is on the home page.