TL;DR
I consolidated six service repos into a single monorepo over three weeks, using Claude Code to handle the mechanical grind: history-preserving git merges, rewriting six CI pipelines into one path-filtered workflow, and deduplicating years of copy-pasted utility code. The merge itself was the easy part. The real work was CI and the shared code β and an AI agent turned out to be great at one of those and dangerous at the other. Here's what worked, what broke, and the checklist I wish I'd had. π
The Problem
Our backend had grown the way most backends grow: one repo per service, created whenever someone needed one. By this year we had six repos β auth, billing, notifications, an API gateway, a worker fleet, and a shared internal dashboard. Each one had its own CI config, its own lint setup, its own slightly-diverged copy of the same retry() and parseConfig() helpers, and its own opinion about which version of TypeScript we were on.
The pain was constant but diffuse:
- Cross-cutting changes took days. Renaming one field in a shared event schema meant six PRs, six reviews, and a merge-ordering dance that broke staging twice.
- Dependency drift. Three repos were on TypeScript 5.5, two on 5.3, one still on 4.9 because nobody wanted to touch it. Same story for ESLint, Node, and our HTTP client.
- Copy-paste utilities. I counted 14 near-identical implementations of the same retry helper across the six repos. Some had bug fixes the others never got.
We'd been saying "we should just do a monorepo" for a year. What finally pushed me to do it was a production incident caused by two services disagreeing about a shared payload shape β a bug that literally could not exist in a monorepo with a shared types package.
The constraint that made it interesting: we couldn't freeze development. Teams kept shipping while I migrated. And I refused to lose git history β git blame on a five-year-old repo is documentation, and squashing it all into one "initial commit" would have thrown that away.
How I Solved It
The stack: Node.js 22, TypeScript 5.5, pnpm 9 workspaces, Turborepo 2.x for task orchestration, GitHub Actions for CI, and Claude Code (Sonnet for the mechanical work, Opus for the gnarly analysis) as the extra pair of hands.
Step 1: History-preserving merges
The git part sounds scary but is genuinely mechanical. For each repo, you rewrite its history so every file lives under services/<name>/, then merge it into the monorepo with --allow-unrelated-histories:
# In a clone of the auth repo
git filter-repo --to-subdirectory-filter services/auth
# In the new monorepo
git remote add auth ../auth-rewritten
git fetch auth
git merge auth/main --allow-unrelated-histories
I had Claude Code write a script that did this for all six repos and then verify the result β and the verification is the part I'd tell you to steal:
# For each source repo: does blame survive?
git log --oneline services/auth | wc -l # commit count matches source repo?
git blame services/auth/src/token.ts # real authors, real dates?
It also diffed the final tree of each services/<name>/ directory against the tip of the source repo. One repo (the dashboard) failed that check β a .github directory collision had silently dropped two workflow files. I would not have caught that by eyeballing.
β οΈ One thing I learned the hard way: do the merges in one sitting and pick a hard cutover date. I initially tried to keep the old repos alive "for a transition period" with a sync script. Don't. It's a distributed-systems problem you're inflicting on yourself. We announced a cutover Friday, merged over the weekend, and archived the old repos Monday morning.
Step 2: One CI pipeline with path filtering
Six repos meant six GitHub Actions setups, and this was where Claude Code earned its keep. Naively running everything on every PR would have meant ~40 minutes of CI for a one-line change.
The fix is path-filtered jobs driven by Turborepo's dependency graph. The workflow computes which packages changed relative to main, and only builds/tests those plus their dependents:
- name: Detect affected packages
run: |
AFFECTED=$(pnpm turbo run build --dry=json --filter="...[origin/main]" \
| jq -r '.tasks[].package' | sort -u)
echo "affected=$AFFECTED" >> "$GITHUB_OUTPUT"
The --filter="...[origin/main]" syntax means "everything that changed since main, plus everything that depends on it" β that second half is the whole point. If you touch the shared types package, all six services rebuild. If you touch the dashboard, only the dashboard does.
I gave Claude Code the six old workflow files and had it produce the unified one. First attempt looked plausible and was wrong in a subtle way: it ported each repo's test job faithfully, but three of the old repos had services: blocks spinning up Postgres containers, and the merged workflow started one shared Postgres for all of them β with three test suites truncating each other's tables in parallel. Tests passed individually, failed in combination, and the failure mode looked exactly like flaky tests. Took me half a day to realize the agent had "helpfully" deduplicated infrastructure that wasn't actually shareable. Each suite got its own database name after that.
End result: median CI time for a single-service PR went from 11 minutes (old repos) to 7 minutes, and a full-graph rebuild (touching shared code) runs in 16 minutes with Turborepo's remote cache doing a lot of lifting.
Step 3: Deduplicating the copy-paste layer
This was the step I most wanted to hand to the agent, and the step where I ended up trusting it least β more on that in the lessons.
The approach that worked:
- Inventory first, change nothing. I had Claude Code sweep all six services and produce a table of duplicated utilities: file, service, and β critically β a diff summary against the other copies. Those 14 retry helpers? Only 9 were actually equivalent. The other 5 had real behavioral differences (different backoff caps, one swallowed a specific error class on purpose, with a comment explaining why).
-
Promote the intersection. The 9 equivalent copies became
packages/shared-utils, with the union of their accumulated bug fixes. Each service's imports got rewritten by the agent β a change I could review as a pure mechanical diff. - Leave the divergent ones alone. The 5 behaviorally-different helpers stayed where they were, each with a comment linking to the shared version and why it differs. Forcing them into one implementation "with options" would have traded visible duplication for invisible coupling.
That 9-vs-5 split is the kind of judgment call you cannot skip. An agent that "deduplicates all duplicated code" without the inventory step would have merged all 14 and shipped at least one production bug β remember, one of those helpers swallowed an error class on purpose.
Lessons Learned
Merging git histories is a solved problem; don't let it scare you off.
git filter-repoplus--allow-unrelated-historiesis 30 lines of script. The scary-sounding part of a monorepo migration is the cheapest part. Budget your fear for CI and shared code instead.AI agents are excellent at porting CI and terrible at merging it. Translating one repo's workflow into a monorepo job? Flawless, six for six. Deciding which pieces of six workflows can share infrastructure? That's a semantics question dressed up as a syntax question, and the agent confidently got it wrong. Review merged CI like you'd review a stranger's code, because that's what it is.
Duplication is data. Inventory it before you delete it. The diff-summary table was the highest-value artifact of the whole migration. Copies that diverged usually diverged for a reason, and that reason is documented nowhere except in the diff itself.
Pick a cutover date and burn the boats. Every hour spent keeping old repos in sync with the new monorepo is an hour spent building a distributed system whose only feature is delaying a decision you've already made.
Verification scripts are the best use of an agent in a migration. Anything the agent changes, make it also write the check that proves the change is safe β blame survival, tree diffs, import-graph assertions. The checks caught two real problems the change scripts introduced. The agent reviewing its own work sounds circular; in practice, generation and verification fail in different ways. β
What's Next
Two things are already on the list. First, remote cache hit rates: our Turborepo cache works but hovers around 70% hits on CI, and I suspect nondeterministic build outputs in two packages are poisoning keys. Second, now that all six services share one dependency tree, I want a single renovate-style upgrade cadence β one PR bumps TypeScript for everyone, CI's path filtering tells us the blast radius, and version drift can't come back.
I'll write both of those up once they've survived contact with reality.
Wrap-up
Total damage: three weeks part-time, six repos in, one monorepo out, zero lost history, and one self-inflicted flaky-test mystery. If you're sitting on a pile of small repos and telling yourself the migration is too risky β the risk is real, but it's concentrated in two places (CI semantics and shared-code judgment calls), and both are manageable if you know to look there.
If this was useful, follow me here on Dev.to β I write weekly about using Claude Code and AI agents on real production work, war stories included. And if you've done a monorepo migration yourself: what broke for you? I'm collecting horror stories in the comments. π¬
Top comments (0)