All articles
9 min read

Translation Branching: Why Your Strings Should Follow Your Git Branches

A shared locale file leaks unreleased copy and wastes review time; translation branches, named merge strategies and role-gated merges fix it.

BranchingWorkflowLocalization

It's Tuesday afternoon and three feature branches are open against the same codebase: a checkout redesign, a referral programme nobody has announced yet, and a settings page refactor that renames half a dozen fields. All three touched en.json this week, because that's where locale files live in most projects – one file, shared by everything that happens to be in flight at the same time.

The checkout redesign reworded the main button, checkout.cta, and added a second one, checkout.cta.express. The referral programme added forty-odd keys under referral.* for a feature marketing hasn't signed off on yet. The settings refactor renamed settings.profile.name to settings.account.displayName, and left the old key in place so it wouldn't break whichever of the other two branches merged first.

If translations live against that one shared file, every one of those changes is visible to every translator working on the project right now, whether or not the feature it describes ships this month, this quarter, or ever. Three problems follow from that, and they tend to show up sooner than anyone expects.

Unreleased copy leaks to production. If pre-translation runs against whatever currently sits in the shared pool, the forty referral keys get translated into nine languages before marketing has approved the feature name. If the launch slips – and referral programmes slip – that copy sits there, fully localized, describing a feature anyone can find by poking around your app's bundle, long before you're ready to talk about it.

Translators spend attention on strings that get cut. A translator who checks a character limit, settles on a natural phrasing and gets a string approved has done real work. If the feature gets reworded twice more and then shelved entirely, the work was genuine but the string was never worth doing yet. Multiply that across a team of translators and a backlog that never stops growing, and a meaningful share of review time goes to copy nobody will ever see rendered.

A hotfix lands in the middle of everything else. Payments breaks. Someone needs checkout.error.declined to say "Card declined – try another method" in production within the hour. Whoever pushes that fix pushes it into the same file that currently holds a half-translated checkout.cta.express and forty untouched referral keys. An emergency export, mid-incident, is exactly the wrong moment to discover that "translated" meant "translated for whatever happened to be in the file", not "translated for what's actually live".

What a translation branch is

A translation branch applies the same idea to strings that a code branch applies to source files. Keys and their values live inside a branch. main is the branch production reads from when it exports. Any other branch is a sandbox: edits, additions, deletions and AI drafts happen there, visible to whoever is working on that branch, invisible to an export of main.

That single property solves all three problems above mechanically, rather than by relying on discipline. Pre-translation on a feature branch never reaches a user until someone merges it. A translator working a feature branch knows exactly which feature they're translating for, because the branch is the feature. And a hotfix to main never has to wait for, or trip over, forty keys that live somewhere else entirely.

Branching and review status answer different questions, and it's worth being clear about which is which. Status says whether a given value is trustworthy; branching says whether it's even visible to production. Designing an AI localization workflow covers the status side in depth – this one is about the visibility side.

Naming the branch after the feature, not after yourself

The convention worth adopting from day one: name the translation branch after the code branch. If the pull request is feature/checkout-v2, the translation branch is feature-checkout-v2 – Translatize branch names stick to letters, digits, hyphens and underscores, so the slash becomes a hyphen and nothing else changes. This sounds obvious and gets skipped constantly, usually because the translation branch is created later, by someone else, who reaches for whatever name is in their head at the time – checkout-redesign-strings, de-review-checkout, their own first name.

Matching names pay off the first time someone has to answer "where did this translation come from". Developers and above can create the branch in the app, then mint a token bound to it under Project Settings → Integrations and point the CLI at it:

npx @translatize/cli init
# writes translatize.config.json: project id, file pattern, nested or flat JSON

export TRANSLATIZE_API_TOKEN=mcni_xxx           # minted for feature-checkout-v2

npx @translatize/cli push                       # new and changed keys go to the branch
npx @translatize/cli status --fail-on-missing   # non-zero exit while a language has gaps
npx @translatize/cli pull                       # one file per language, from the branch

There's no --branch flag in there, and that's deliberate. A standard token acts on the branch it was minted for and only that branch, so a token for feature-checkout-v2 can't push to or read from main even if the CI secret carrying it leaks. Automation that needs branches of its own – a CI job per pull request, or an AI coding agent working through the MCP server – gets a create-own token instead: it can open branches forked from its base branch and work on the ones it created, and nothing else.

If your source files are JSON – the most common case, and the one translating JSON files with AI covers in more depth – push and pull read and write the same nested-or-flat structure your app already uses, just scoped to whichever branch the token belongs to.

Comparing a branch against main

Before merging anything, you want to know what actually changed, not what you remember changing. Comparing feature-checkout-v2 against main in the app returns three buckets: keys added on the branch that main doesn't have, keys the branch edited that also exist on main, and keys the branch removed. For the example above, that comparison shows checkout.cta.express as an addition and checkout.cta as changed, because the branch reworded it.

On a real feature branch the numbers might be fourteen keys added, three changed that also exist on main, and one removed. The additions and the deletions merge cleanly under almost any strategy – there's nothing on the other side to disagree with. The changed-on-both-sides count is the number worth reading closely before merging, because it's the only category where the choice of strategy actually changes the outcome.

It gets interesting the moment a second branch edits the same key.

mainfeature-checkout-v2Branch from mainAdd and edit keys14 added, 3 changedHotfix shipsmain keeps movingAI drafts, reviewdraft → approvedCompare with mainadded · changed · removedMergeadmin or owner
Nothing on the branch is visible to an export of main until the merge. Abandon the feature instead, and deleting the branch discards its keys without main ever having seen them.

A conflict, worked: two branches edit checkout.cta

Say feature-checkout-v2 changes the English value of checkout.cta from "Buy now" to "Complete purchase", and in parallel feature-pricing-experiment – a branch testing different button copy for an A/B test – changes the same key to "Get it today". Both branches also carry German and French translations in various states, and neither has merged yet. Now someone needs to merge both into main, and the key collides.

What happens next depends entirely on which merge strategy gets picked, and that's worth understanding before you need it, not during an incident:

StrategyWhat happens to checkout.ctaWhen you'd choose it
overwrite (default)Non-destructive union; on this one conflicting key the source branch's value wins, and every other key on the target is untouchedRoutine feature merges, where you trust the branch being merged in
replaceDestructive; main's entire checkout.cta state, and everything else, is overwritten with the source branch's copyRestoring a known-good snapshot, almost never a routine feature merge
keep-newerWhichever branch edited checkout.cta more recently wins, compared label by labelBranches that have run in parallel a while and you don't want to review every key by hand
manualThe conflict surfaces – key, language, source value, target value – and a person picks source, target, or types a replacement, before the merge commitsA key like this one, on a user-facing checkout button, where the two values genuinely disagree about what the product should say

For checkout.cta specifically, manual is the honest answer. "Complete purchase" and "Get it today" aren't a conflict to settle by timestamp – they're two different decisions about what the button should say, and somebody has to actually choose. overwrite would silently pick whichever branch happens to merge second, which is a coin flip dressed up as a strategy. That's a perfectly fine coin flip for the ninety keys on the branch that nobody else touched. It's the wrong tool for the one key two people made a deliberate decision about.

The same question exists for the German and French values attached to that key, and it's easy to miss if you only look at English. Manual resolution works per key and per language, so you can take the source branch's English and keep the target's German if that is what the two teams agreed – or type a third value that reconciles them. Nobody has to accept a whole branch's view of a key just to get one language right.

Who is allowed to merge

None of this matters if anyone can merge into main at will, so the permission is deliberately narrow. Developer and above can create a translation branch. Only the branch's creator or a project admin can delete it. Only an admin or owner can merge a branch into main. A translator can work freely inside a feature branch – writing and editing strings, submitting them for review – with no path at all to changing what production currently serves, because the merge step itself sits above their role.

That's what makes branch-per-feature practical rather than theoretical: the people doing the bulk of translation work are never the ones who can accidentally ship it.

The hotfix case

Hotfixes are the strongest argument for branching, not the exception to it. When checkout.error.declined needs a one-line fix in production right now, that fix can go straight onto main as its own small change, because main isn't also carrying forty half-finished referral keys – those live on their own branch, untouched. The emergency export contains exactly the hotfix, because that's all that's in the branch being exported. Nobody has to remember to isolate anything in the middle of an incident; the isolation already happened, weeks earlier, when the referral programme got its own branch instead of writing straight into the shared pool.

Abandoned features cost nothing

The reverse case is just as useful. Say the referral programme above gets shelved after a quarter of changing requirements. On a shared file, someone now has to go through and manually remove forty keys without breaking anything added since, in every language, hoping none of them got reused elsewhere in the meantime.

On a branch, abandoning the feature is deleting the branch. The forty keys, their translations in every language, their review history – all of it goes with the branch, and main was never touched by any of it. Nothing to clean up, nothing to accidentally leave behind, no stale German string for a button that no longer exists anywhere. The only requirement, per the role rules above, is that the deletion comes from the branch's creator or a project admin.

This is worth planning for rather than discovering by accident, because a project with several teams usually has a few of these half-finished branches sitting around at any given time. Knowing that none of them can leak into main by default is what makes it safe to let a branch sit untouched for a month while a feature's requirements settle, instead of pressuring someone to either finish the translation early or delete the branch defensively just to keep the shared state clean.

Starting your next feature branch

The sequence is the same every time once it's set up: create the branch, name it after the code branch, point a scoped token at it, let the team work in isolation, compare against main before merging, and pick a strategy on purpose instead of by default. The strategy you'll reach for most often is overwrite. The one you'll be glad exists the first time two branches genuinely disagree is manual. Either way, nothing reaches a user until somebody with the right role decides it should.

Keep reading