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.
In practice that means:
- To see who wrote a line and why,
git blameworks 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
COPYpaths. - 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,
.gitignorepatterns, 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.