Two teams. One codebase. A real-time Git mirror I never planned to build. The developers lived in GitLab. Ops lived in Bitbucket. Branches drifted apart a little more every day, merge conflicts stacked up, and both sides quietly blamed the other for the mess. Management kept asking the same question in every standup: why does nothing line up? And everyone offered the same tired answer, just pick one platform and force the switch.

I did not build the real-time GitLab-Bitbucket sync because it was elegant. I built it because forcing a migration is a people problem dressed up as a technical one, and I am an engineer, not a politician.

The problem was never the tool

Here is the part most "just consolidate your VCS" advice skips. The developers were not wrong to love GitLab. Ops was not wrong to love Bitbucket. Both tools were doing exactly what each team needed. The friction was not GitLab versus Bitbucket. It was two groups of people being told one of them had to lose their daily workflow so a slide deck could say "single platform."

You can win that argument in a meeting. You cannot win it in practice. People route around forced migrations. They keep a shadow repo, they push to the old remote "just this once," and six weeks later you have two sources of truth and a very expensive lesson.

So I stopped trying to pick a winner. I let both sides keep their tool and made the machines do the reconciling.

The architecture: bidirectional sync on every push

The core is a bidirectional Git mirror driven by GitLab CI. Every push event fires a pipeline. That pipeline does the unglamorous work:

  • Conflict-aware sync so a push on one side does not blindly clobber the other.
  • Branch mapping filtered by naming convention because not every branch should cross over, and the convention decides which ones do.
  • A retry mechanism with real alerting, because a sync that fails silently at 2 AM is worse than one that fails loudly at noon.

On paper that is a weekend project. On paper. The concept is genuinely simple: watch one remote, replay to the other, repeat. The concept was never the hard part.

The parts nobody warns you about

Then everything that could go wrong did, one edge case at a time.

  • Force pushes shattered the mirror state. Someone rewrites history on GitLab, the mirror faithfully tries to fast-forward Bitbucket, and now the two histories have diverged in a way plain replay can never reconcile.
  • Auth tokens expired mid-sync on a Friday evening. Not during business hours, not with a warning. The token died, the sync stalled, and nobody noticed until Monday.
  • Webhook storms during release day flooded the pipeline queue. Twenty pushes in two minutes turned into a backlog of sync jobs stepping on each other.
  • Two developers pushed the same file to both platforms at the exact same moment. A genuine race condition across two remotes that do not know the other exists.

Every single one of these actually happened. Every single one needed its own fix. And every fix taught the same lesson: the hard problem is not the sync, it is all the ways sync can fail silently while everyone assumes it is fine. Drift does not announce itself. It just sits there for days until someone deploys the wrong revision.

Error handling is the product

I want to be honest about what actually made this work, because it was not clever Git plumbing. It was boring, defensive error handling.

The sync itself is maybe a third of the code. The rest is detecting force pushes and handling them deliberately instead of pretending they cannot happen. Rotating and validating tokens before a job, not after it fails. Rate limiting and de-duplicating webhook events so a release-day storm collapses into one clean sync. Locking on a per-file basis so simultaneous pushes serialize instead of racing. None of it is impressive to look at. All of it is why the thing stayed up.

The results

After the edge cases stopped surprising me:

  • Sync latency under 30 seconds between the two platforms.
  • Zero drift, the codebase stayed unified no matter which remote you looked at.
  • The cross-team blame game gone, because there was nothing left to blame.

Developers kept GitLab. Ops kept Bitbucket. Management got the single source of truth they actually wanted, which was never "one platform", it was "the code matches." The mirror held over months of operation. Not because it is perfect. Because the error handling is.

The lesson

Sometimes the best migration is no migration at all, just a really good mirror. The engineering solution outlasted every meeting compromise because code does not play politics. When you find yourself in a tool war, ask whether it is actually a tool problem. Often the fastest path is not making people change, it is building the boring layer that lets them not have to.