At FilmTailor we'd got to the point where every new service meant a new repo, a new pipeline, and a new set of "wait, which version of the shared library is this one on?" conversations. Nothing was broken exactly. It was just death by a thousand context switches: five terminals open, five READMEs slightly out of date, and PRs that touched three repos because one API contract changed.
The fix was obvious enough on a whiteboard: pull everything into a single monorepo. The bit that wasn't obvious was how, if you actually care about keeping your git history intact rather than just copy-pasting source files in and losing a decade of blame annotations in the process.
Beyond fixing the "which repo has the latest version of this?" problem, a couple of other benefits made the decision easy:
- Sharing code without an internal NuGet feed. Shared models, DTOs, and common classes could just be referenced directly instead of living behind a private package feed. That sounds like a nice-to-have until you've actually run one: every change means a PR to the shared library, a version bump, a PR to consume the new version in each service, and inevitably a service or two lagging a few versions behind because nobody got round to bumping it. A monorepo makes shared code an internal project reference instead of a package dependency, and the sync problem disappears.
- A clean path to Aspire. (It's just "Aspire" these days, not ".NET Aspire" — Microsoft dropped the prefix. See the Aspire docs.) Aspire wants to orchestrate your whole solution — all your services, their dependencies, the lot — from one AppHost. Multi-repo isn't officially supported yet: Aspire maintainer David Fowler has said it's on the roadmap but more mid-term than short-term (there's a long-running GitHub discussion tracking it, with a mention on the public roadmap too). In the meantime people get by with workarounds — "uber solutions" that clone every repo into sibling folders, git submodules, or shipping shared bits as NuGet packages. Fowler's recurring response to multi-repo requests in that thread is worth sitting with: he keeps asking people why they've split things into separate repos to begin with, since for a lot of teams the honest answer amounts to "no strong reason, that's just how it started." If you're building fresh and don't have a hard requirement (separate access control, part of the codebase going open source, that sort of thing) to keep repos apart, starting as a monorepo sidesteps the multi-repo problem entirely rather than working around it later.
This post covers how we did the merge itself. A follow-up will cover the bit that made the monorepo actually pleasant to work in day to day: tag-triggered selective builds, so a change to one service doesn't kick off a full pipeline run for all of them.
Why bother preserving history at all?
You could just copy the files across and commit them fresh. It's faster, and nobody's going to fire you for it. But you lose:
-
git blame— the ability to ask "why is this line here?" and get a real answer - Bisectability — hunting down a regression across the service's whole lifetime, not just since the day it moved house
- Tags and releases — your version history becomes fiction
If none of that matters for your case, skip the whole exercise and copy the files in. If it does, you'll want a tool built specifically for surgically rewriting history rather than just deleting things.
The tool: git-filter-repo
We used git-filter-repo, which is the tool the Git project itself now points people towards for rewriting history — it replaced the old filter-branch, which is slower and has enough sharp edges that the docs actively discourage using it.
On macOS:
brew install git-filter-repo
A word of warning: this is a history-rewriting tool. That's the whole point of it, but it also means a typo in the wrong command can quietly and permanently mangle a repo. Do all of this in a throwaway clone, never your working copy, and never anything pushed to a shared branch until you're happy with the result.
It's worth having two terminal panes open for this: one in your new monorepo, one in a scratch clone of the repo you're merging in.
The general flow
1. Create the monorepo
Spin up a new repository, initialise it with a README.md, and push it to GitHub. This is your destination.
2. Clone the repo you're merging — into a fresh directory
In your second terminal, do a fresh clone of the service repo you want to bring in. Fresh, not your existing working copy — you're about to rewrite its history, and you don't want that to leak back anywhere it shouldn't.
3. Prefix the tags
If you don't do this, your shiny new monorepo will have tag collisions the moment you merge in a second service (both had a v1.0.0 at some point, guaranteed). Rename tags with a prefix before anything else happens:
git filter-repo --tag-rename '':'my-service-'
This turns v1.0.12 into my-service-v1.0.12.
Gotcha: git filter-repo removes the origin remote as a safety measure, specifically so you can't accidentally push a rewritten history back over the original. Run git remote -v afterwards and you'll see it's gone. That's expected, not a bug you've introduced.
4. Reclaim your commits (optional)
If you want those green squares to keep showing up on your GitHub profile after the merge, rewrite the author email to match whichever account you want the credit against:
git filter-repo --email-callback '
return email if email != b"you@old-company.com" else b"you@personal-email.com"
'
5. Move everything into a subdirectory
Your service's files currently sit at the repo root. In the monorepo they need their own home:
git filter-repo --to-subdirectory-filter src/my-service
Every commit in the rewritten history now looks like it always lived under src/my-service.
6. Back in the monorepo: create a branch
Switch to your first terminal (the monorepo) and create a branch to integrate into:
git checkout -b integrate-my-service
7. Point a temporary remote at your rewritten clone
git remote add temp /path/to/your/tmp/my-service
8. Fetch it
git fetch temp
9. Merge, allowing unrelated histories
Because these two repos have never shared a common ancestor, Git will refuse a normal merge unless you tell it that's fine:
git merge temp/main --allow-unrelated-histories
10. Clean up the temporary remote
You won't need it again, and if you're merging in several services one after another, leftover temp remotes just get confusing:
git remote remove temp
11. Push, tags included
git push --tags
12. Open the PR — and do not squash-merge it
This is the one that matters most. Squash-merging collapses everything you just carefully preserved back into a single commit. All that effort to keep git blame and bisect working survives right up until someone hits the wrong merge button. Use a regular merge commit.
Doing this more than once
Repeat steps 2–10 for each additional repo, using a fresh scratch clone and a new branch each time. Resist the temptation to batch them — merging one at a time means that if something looks wrong, you know exactly which merge did it.
The bit after the merge
Getting the code into one repo is only half the job. A few things need sorting immediately afterwards, before the monorepo is actually usable:
-
Multiple
.gitignorefiles. Each service arrives with its own.gitignore, now sitting in its own subdirectory (src/my-service/.gitignore,src/other-service/.gitignore, and so on). These still work exactly as before — git happily honours a.gitignorein any directory, scoped to that directory and below — so there's no urgent need to merge them. But it's worth doing anyway: you'll almost certainly find each one repeats the same handful ofbin/,obj/, and IDE-specific entries, and a single root-level.gitignoreis easier to keep consistent than five scattered copies that quietly drift apart. - The pipeline. Whatever CI/CD each service had before the merge, you now need something that builds the monorepo. The quick option is a single pipeline that builds and tests everything on every push — it works, and it's honest about the fact that you haven't solved the "one service, one build" problem yet. Don't over-engineer this straight away. Get something green first; the tag-triggered selective build setup that fixes the "why is a one-line change rebuilding all five services" problem is worth its own post (coming next), not a rushed afternoon bolted onto the migration.
-
An uber solution. With everything in one repo, it's worth creating a single
.slnthat references every project, rather than leaving each service with its own solution file. This is what actually makes the monorepo pay off day to day: one solution to open, cross-service refactoring that just works in the IDE, and — as covered above — it's the same "uber solution" pattern people currently use to work around Aspire's lack of native multi-repo support. Once you're here, wiring up an AppHost across all your services is a normal Aspire setup rather than a workaround.
None of this needs to happen in the same PR as the merge itself. In fact it's better if it doesn't — keep the history-preserving merge as its own reviewable change, then tidy up in follow-up PRs once you can see the whole repo laid out in front of you.
What's next
With everything living in one repo, the next problem shows up almost immediately: a one-line fix to a single service shouldn't trigger a full build of everything. That's what the follow-up post covers — tag-triggered selective builds using an Azure DevOps release orchestrator pipeline, so only the services that actually changed get built.
Top comments (0)