What actually goes wrong
A locale file is source code that a non-developer edits. That is the whole difficulty. Git is very good at letting two people change the same file, and completely indifferent to whether the result still means anything — and a translation file is nothing but meaning.
Three things break, in this order:
- New keys arrive without translations. A branch adds
checkout.summary.titlein the source language. When it merges, every other language is missing that key. What the user sees depends on your fallback: the English string, the raw key, or an empty element. - Two branches edit the same file. Not the same key — the same file. Git merges JSON line by line, so two people appending keys to the same object conflict textually even when the changes are perfectly compatible.
- The source language moves under the translators. Someone reworded
cart.emptyon main. The nine translations of the old wording are still there, still marked as done, and nothing in the file records that they now answer a different sentence.
The third one is the expensive one, because it fails silently. The first two are noisy and get fixed; a translation that is subtly wrong ships and stays.
The four workflows
Every team doing this is doing one of four things, and most have never chosen which. It is worth naming yours before changing it.
| Workflow | Works when | Costs you |
|---|---|---|
| Ship first, translate later | You have a presentable fallback and releases are frequent. | Every release has a window of mixed-language UI. |
| String freeze | Releases are dated and a week apart or more. | Blocks trunk-based development. Not compatible with continuous delivery. |
| Developers translate on the branch | Two or three languages, all spoken in the team. | No reviewer, no glossary, and the same word ends up translated two ways. |
| Branch the translations too | Translation is somebody's job and the branch lives more than a day. | Needs a tool that has a concept of a branch. Most do not. |
The first is the honest default and there is nothing wrong with choosing it deliberately. What makes it painful is choosing it by accident, which is what happens when the fallback is the raw key: a release goes out, a screenshot arrives in a support channel with checkout.summary.title in the middle of it, and translation becomes an emergency instead of a step.
If you take one thing from this page, take that one: decide what an untranslated key looks like before deciding when translation happens. In i18next that is fallbackLng and parseMissingKeyHandler; in Transloco a fallbackLang and a missing-handler; in vue-i18n fallbackLocale plus fallbackWarn so the miss shows up in the console during development rather than in production.
Branching the files too
The fourth workflow is the one with the fewest surprises, and the one the tooling usually does not support. The idea is exactly the git one: a branch is a place where changes can be incomplete without being visible.
- A feature branch is created. The keys it needs are added in the source language only, by whoever writes the UI.
- Those keys become visible to translators as belonging to that branch. They can be worked on, left half-done, and thrown away with the feature.
- The branch cannot merge until it is complete, or it merges with a known gap that somebody signed off on. Either way the state is explicit.
- When the code merges, the translations merge. One event, not two.
What this buys is not speed. It is that an unfinished translation is never in the same place as a finished one, so “is this ready?” stops being a question anyone has to ask a person.
Merging locale JSON
Whichever workflow you pick, the files still have to come back together. This is what a conflict in a locale file looks like, and the reason it happens even when nothing is really in conflict:
{
"cart": {
"empty": "Your cart is empty",
<<<<<<< HEAD
"promoCode": "Promo code"
=======
"giftNote": "Add a gift note"
>>>>>>> feature/gift-notes
}
}Both branches added a key to the end of the same object. Nothing semantically clashes. Git sees two different sets of lines in the same place and stops.
What helps
- Sort keys deterministically and write one key per line. Whatever writes your files — an export, an editor, a script — should produce byte-identical output for identical content, or every diff contains noise nobody reviewed.
- Split the file. Namespaces, scopes, one file per feature area: a conflict needs two people in the same file, so the fix is fewer people per file. See splitting a language across several files for the shapes each library expects.
- Mark them as needing a human. A
.gitattributesentry that turns off the text merge forces resolution instead of producing a plausible-looking file:
# Resolve locale files by hand. A three-way text merge on JSON
# produces a file that parses and means something else.
src/locales/*.json -mergeWhat does not help
A union merge driver — *.json merge=union — is the tempting one and it is a trap. It resolves the conflict by keeping both sides' lines, which for JSON means a missing comma, a duplicated key, or both. The merge succeeds, the build fails, and if the duplicate happens to be syntactically valid the file silently loses one of the two values. Do not use it on structured data.
Wiring it into CI
Whatever holds the translations, something has to move strings between it and the repo, and that something should not be a person with a download button. The job runs on the branch it is already on, which is the whole trick: the branch name is the coordinate.
mergua-sync:
stage: .pre
image: alpine:latest
before_script:
- apk add --no-cache curl jq git
script:
# Runs on the branch the pipeline is already on: pushes new
# source keys, pulls back whatever has been translated since.
- curl -sf https://api.mergua.com/v1/mergua.sh | sh -s -- syncTwo properties are worth insisting on whatever tool you use. It has to be idempotent, because it will run on every pipeline including the six re-runs of a flaky test. And it has to fail loudly on a conflict rather than pick a side, for the same reason the union driver is a trap.
Every flag, environment variable and CI provider is in the CLI reference, including what the job does when a branch does not exist on the other side yet.
Where Mergua fits
Mergua is built around the fourth workflow, which is why this page exists: translations live on branches with the same names as your git branches, they are reviewed before they merge, and the CLI above is how they get in and out.
Where they merge, and what cannot be touched
The branch a merge lands in is a project setting rather than a fixed name. A team on develop points the project at develop, and every merge, diff and conflict list from then on is against that branch — and a single merge can still be aimed at another open branch, for the release that is not going into develop yet.
Picking a target protects it, and that is the whole of what protection means here: the branch cannot be renamed or deleted, and a merge cannot tidy it away afterwards. It is not a permission — who may write is the member's role, and whether a value may reach the target unreviewed is the approval setting. The two are worth keeping apart, because a protected branch that nobody may edit is a different arrangement from a protected branch everybody may edit.
And a branch that has been open for two weeks is not stuck with the content it started from: it can pull the target's newer translations in whenever, from main or from another branch. Where both sides moved the same value, the sync counts those as conflicts to settle rather than picking a winner quietly. The manual has the exact rules for all three.
It is not the only tool that can do a version of this — Crowdin creates branches from your repo and Phrase syncs branch names on its Business plan. If you are on Angular, React or Vue, the framework guides have the concrete setup for your locale folder.