Skip to content

fix(i18n): stop xcstrings from diffing on every Xcode build#832

Open
jbergler wants to merge 3 commits into
ryanbr:mainfrom
jbergler:fix/xcstrings-build-diffs
Open

fix(i18n): stop xcstrings from diffing on every Xcode build#832
jbergler wants to merge 3 commits into
ryanbr:mainfrom
jbergler:fix/xcstrings-build-diffs

Conversation

@jbergler

@jbergler jbergler commented Jul 26, 2026

Copy link
Copy Markdown

Summary

  • Root-caused why .xcstrings String Catalogs show a full-file diff after nearly every local Xcode build/run: Xcode auto-syncs catalogs against source (SWIFT_EMIT_LOC_STRINGS: YES) via its own IDE build path only (confirmed xcodebuild CLI never triggers it), rewriting the whole file in its own formatting and key order every time.
  • Xcode's key ordering isn't alphabetical or locale-collated (looks like Swift's hash-seeded Dictionary order) and can't be reproduced byte-for-byte by outside tooling — that part is inherent and not fixable from here.
  • What is fixable: the repo's own catalog writers (seed-string-catalog.py, translate-de.py, translate-it.py) each wrote catalogs via plain json.dumps(indent=2), a format different from Xcode's own — compounding noise on top of Xcode's unavoidable reordering.
  • Adds Tools/xcstrings_format.py: a stable, deterministic canonical writer (sorted keys, Xcode's " : " spacing and no-trailing-newline convention, and its non-collapsing empty-object formatting), used by all three catalog-writing scripts, plus a CLI to canonicalize any catalog on demand — collapses a build-triggered working-tree diff back to real changes for a quick "did anything actually change" check.
  • Adds Tools/git-hooks/pre-commit to re-canonicalize any staged .xcstrings file automatically (opt in via git config core.hooksPath Tools/git-hooks), so committed history stays diff-friendly regardless of what Xcode's local rewrite left behind.
  • Documents the whole mechanism in docs/CONTRIBUTING.md.

Not in this PR: an earlier version of this PR also landed ~221 strings that Xcode's extraction had synced in from Swift source but that had never made it into the catalogs (several PRs were merged without a local Xcode compile, per project convention). CI's i18n-coverage gate correctly failed on that — full de/es/fr/pt-PT translation coverage is a standing invariant here, and those 221 strings had no translations yet. That's real, separate work, not something to auto-generate alongside a formatting fix, so it's been dropped from this PR: all four catalogs are now reformatted-only, byte-for-byte identical in content to main. The backlog itself is left for separate follow-up work with real translations.

Test plan

  • cd Tools && python3 -m unittest test_xcstrings_format -v — 8/8 pass
  • cd Tools && python3 -m unittest test_i18n_audit -v — 34/34 pass (untouched, still green)
  • python3 Tools/i18n_audit.py --ci origin/main — green (this is what CI runs; confirmed locally after dropping the untranslated backlog)
  • Verified all four catalogs are content-identical (via JSON comparison) to their main versions — this PR only changes formatting
  • Verified the canonicalizer is idempotent (re-running reports "already canonical", zero diff)
  • Smoke-tested the pre-commit hook end-to-end in a scratch repo: a non-canonical staged .xcstrings file is normalized and re-staged before commit
  • Confirmed via direct experiment that plain xcodebuild CLI builds (macOS Strand and iOS NOOPiOS schemes) never touch .xcstrings files, including after adding a new String(localized:) call to source — only Xcode.app's own build/run path does

jbergler and others added 3 commits July 26, 2026 20:42
Xcode rewrites String Catalogs (reformats + reorders keys) on essentially
every local build via SWIFT_EMIT_LOC_STRINGS — confirmed this doesn't
reproduce under plain `xcodebuild` CLI, only Xcode.app's own build/run path.
Xcode's key order isn't alphabetical or locale-collated (looks like Swift's
hash-seeded Dictionary order), so no external tool can match it byte-for-byte
— that part isn't fixable. What was fixable: the repo's own catalog writers
(seed-string-catalog.py, translate-de.py, translate-it.py) used plain
json.dumps(indent=2), a different format from Xcode's own, so every commit
compounded the noise on top of Xcode's unavoidable reordering.

Adds Tools/xcstrings_format.py: a stable canonical writer (sorted keys,
Xcode's " : " spacing and no-trailing-newline convention, and its
non-collapsing empty-object formatting) used by all three catalog-writing
scripts, plus a CLI to canonicalize any catalog on demand — collapses a
build-triggered working-tree diff back down to real changes for a quick
sanity check. Tools/git-hooks/pre-commit re-applies it to staged catalogs so
committed history stays diff-friendly regardless of what Xcode did locally
(opt in: `git config core.hooksPath Tools/git-hooks`).

Also lands the real backlog the canonicalizer surfaced: 198 strings added to
Swift source across past PRs (several merged without a local Xcode compile,
per project convention) had never been synced into
Strand/Resources/Localizable.xcstrings until this build did it.

Documents the whole thing in CONTRIBUTING.md so the next person who sees a
scary full-file diff after pressing Run knows it's expected, not a bug.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0181ZawDbdJ5dpNtYegkL53s
Code review on the previous commit caught two inaccuracies: it claimed the
writer matches Xcode's own serialization byte-for-byte (it can't — Xcode's
key order isn't reproducible, as the xcstrings_format.py module docstring
already correctly says), and its documented run command
(`python3 -m unittest Tools.test_xcstrings_format -v` from repo root) fails
with ModuleNotFoundError since Tools isn't a package. Matches the one
invocation that actually works.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0181ZawDbdJ5dpNtYegkL53s
…mmit

CI's i18n-coverage gate (Tools/i18n_audit.py --ci) treats full de/es/fr/pt-PT
translation coverage as a standing invariant (the ryanbr#453 backlog is closed),
and correctly failed this PR: the 221 strings the earlier commit synced in
from Xcode's extraction had no translations yet, which is real, separate
work — not something to wave through alongside a formatting fix, and not
something to auto-generate here (see the project's own caution against
machine-translated "slop", ryanbr#543).

Restores all four catalogs to their pre-PR content (verified byte-for-byte
identical via JSON comparison against origin/main), reformatted only through
Tools/xcstrings_format.py. Confirmed `i18n_audit.py --ci` is green again and
the 42 Python unit tests still pass. The 221-string backlog itself is
separate follow-up work, to be landed with real translations.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0181ZawDbdJ5dpNtYegkL53s
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant