Workflow

Your code branches. Your translations probably do not.

A feature branch adds twelve new keys. They land in the locale file on main, untranslated, the moment it merges — or they do not land at all. Here are the four ways teams deal with that, and what each one costs.

Published Updated

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.title in 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.empty on 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.

WorkflowWorks whenCosts you
Ship first, translate laterYou have a presentable fallback and releases are frequent.Every release has a window of mixed-language UI.
String freezeReleases are dated and a week apart or more.Blocks trunk-based development. Not compatible with continuous delivery.
Developers translate on the branchTwo or three languages, all spoken in the team.No reviewer, no glossary, and the same word ends up translated two ways.
Branch the translations tooTranslation 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.

  1. A feature branch is created. The keys it needs are added in the source language only, by whoever writes the UI.
  2. 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.
  3. 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.
  4. 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:

src/locales/en.json (during a merge)
{
  "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 .gitattributes entry that turns off the text merge forces resolution instead of producing a plausible-looking file:
.gitattributes
# Resolve locale files by hand. A three-way text merge on JSON
# produces a file that parses and means something else.
src/locales/*.json -merge

What 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.

.gitlab-ci.yml
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 -- sync

Two 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.

Keep reading

Try it on one branch.

Upload the locale files you already have and see the whole loop — branch, translate, review, sync — on the free tier.

Create your account