Xcode が黙って更新する Localizable.xcstrings のドリフト — PR マージ前に必須の 1 コマンド

AI 主体で執筆

Xcode の String Catalog は「Text("Foo") を書いたら自動でローカライズキーが追加される」という便利機能を提供する。便利なのだが、この自動追加は ビルド時 に起きる。開発者がコミットし忘れると Localizable.xcstrings はローカルだけ先に進み、リポジトリには反映されない。こうして蓄積したサイレントなドリフトが、v1.0.1 の release prep PR で一気に表面化した。踏んだ罠と、もう踏まないための 1 コマンドをメモしておく。

現象:勝手に xcstrings が変わる

SwiftUI で UI を書いていると、こういうコードを追加する場面が日常的にある。

Text("メモを書き出す")
    .font(.headline)

ビルドすると、Xcode は Localizable.xcstrings を開いて次のエントリを「勝手に」追加する。

{
  "sourceLanguage" : "ja",
  "strings" : {
    "メモを書き出す" : {
      "extractionState" : "manual",
      "localizations" : { }
    },
    // ...
  }
}

String Catalog の強みは、この「自動抽出(extractionState: manual)」で翻訳漏れを防げるところだ。コードに書いた文字列は必ずキーとして xcstrings に登場し、翻訳者はそこに各言語を埋めていけばいい。

ただし自動抽出のタイミングは Xcode のビルド時で、コミットするかどうかは開発者次第。ここにズレが生まれる。

なぜドリフトが蓄積するのか

一連の流れはこうなる。

  1. 機能ブランチ A で Text("メモを書き出す") を追加
  2. Xcode で実行(シミュレータやプレビュー)して動作確認
  3. その瞬間、Localizable.xcstrings にキーが追加される
  4. 開発者は Swift のコード変更だけを git add .(or 個別 add)して PR を出す
  5. xcstrings の変更は 「いつの間に入っていたか分からない」状態で working tree に居座る
  6. 別の機能ブランチ B に切り替えると xcstrings の変更が一緒について回る
  7. 最終的に release prep ブランチか別の PR で「身に覚えのない翻訳キー追加」として拾う

厄介なのは 3〜4 のステップが完全にサイレントであることだ。Xcode は「xcstrings を変更しました」と通知しない。ビルドログを詳細に眺めれば分かるのかもしれないが、日常的に確認するものじゃない。コードレビューでも xcstrings の diff は JSON の塊で読み飛ばされがち。

結果、「ローカルで先に進んでいる xcstrings」と「リポジトリで止まっている xcstrings」のドリフトが、気付かないまま数 PR 分ずつ積み上がる。

実際にやらかした事例

60d Memo の v1.0.1 リリース準備で、実際にこれを踏んだ。

v1.0 では素直に Localizable.xcstrings を管理していたつもりだったが、v1.0.1 準備ブランチで PR をまとめていると、Localizable.xcstrings身に覚えのない 7 つの新規キーが混ざっていた。

$ git diff main -- 60dMemo/Localizable.xcstrings
+ "件数を更新中..." : { ... },
+ "同期に失敗しました" : { ... },
+ "アーカイブを含める" : { ... },
+ "Drive から再取得" : { ... },
+ ...

7 つともコード上は確かに使われている文字列だが、それらの PR はすでに別々にマージ済みで、release prep PR で足し算されるのはおかしい。どこで混入したのか追う羽目になった。

追跡には git log -p を使うのが一番早い。

git log -p -- 60dMemo/Localizable.xcstrings | grep -B 2 '"件数を更新中..."'

この出力から「このキーはいつのコミットで初めて xcstrings に登場したか」が分かる。初出コミットが機能 PR ではなく、関係のない別の PR に紛れていることが多い。機能実装した PR のコミット時点では xcstrings が add されていなかったので、次に xcstrings を add したコミットに丸ごと合流してしまっている。

結果、git blame がをつく。「この翻訳キーは PR #X で追加された」と表示するが、実装としてその文字列を書いたのは PR #Y(もっと前)だ。翻訳者が何を根拠に作業すればいいのか分からなくなる。

PR マージ前の 1 コマンドで防ぐ

ドリフトの予防策として、PR マージ前の自己チェックを運用ルールに追加した。やることは 1 行のコマンドだけ。

git status --short 60dMemo/Localizable.xcstrings

これが空行ならクリーン。何か出力されていたら、その差分が 今の PR の一部として入るべきか、別 PR に切り出すか、捨てるかを判断する。

判断基準はシンプルだ。

このチェックを .cursor/rules/github-workflow.mdc に条文として追加した。AI エージェントがコミット前に必ず走らせるようにすれば、人間が忘れてもカバーできる。

運用原則: xcstrings はコードの副産物ではなく「公開 API」だと思う。翻訳者にとってのキー一覧であり、後で別言語を足すときの基盤でもある。意図しない変更を混ぜると後から必ず困る。

pre-commit hook にしたくなるが、実は難しい

「1 コマンドを忘れることが問題なら、pre-commit hook で自動化すれば?」とすぐ思う。だが実際に hook を書こうとすると、判断基準が曖昧になって詰まる。

素直な hook の書き方はこうだろう。

#!/bin/sh
# pre-commit: Swift に変更があるのに xcstrings が staged されていなかったら警告
changed_swift=$(git diff --cached --name-only | grep -E '\.swift$' || true)
changed_xcstrings=$(git diff --cached --name-only | grep -E 'Localizable\.xcstrings$' || true)
if [ -n "$changed_swift" ] && [ -z "$changed_xcstrings" ]; then
    # ローカルに xcstrings の変更が残っていたら警告
    untracked=$(git diff --name-only | grep -E 'Localizable\.xcstrings$' || true)
    if [ -n "$untracked" ]; then
        echo "WARN: Swift が変更されていますが、xcstrings の差分が unstaged です"
        exit 1
    fi
fi

問題は 「Swift が変更されても xcstrings を触る必要がないケース」があること。既存の Text を使い回しただけ、ロジック変更だけ、リファクタリングだけ、などなど。この hook は偽陽性が多発して、結局 git commit --no-verify で回避する癖がつく。それではルール文書より弱い。

逆に「xcstrings を触っていないのに Swift を変更した」を常に blockers にすると、過剰防衛で開発が止まる。

現実的な落としどころは、hook は警告だけ出して止めない方針にすること。

echo "INFO: xcstrings に unstaged な変更があります。必要なら add してください: $untracked"

警告が目に入れば「あ、確認しよう」が走る。強制はしない。これくらいが実務では回しやすい。

代わりに、コミットメッセージレベルでのセルフチェックをルール化した。コミット前に必ず 1 コマンド叩く、が PR テンプレートにも書いてあれば、人間も AI エージェントも見落としにくい。

発覚したあとのリカバリ

すでにドリフトを他 PR に紛れ込ませてしまった場合は、焦らず追う。

  1. git log -p -- <xcstrings> | less で履歴を時系列に追う
  2. 各追加キーの 初出コミット本来の機能実装コミットを突き合わせる
  3. 両者が一致していなければドリフト。本来の PR に cherry-pick し直すか、release notes でまとめて言及するかを決める
  4. ひどい場合は、release prep PR 内で xcstrings だけ別コミットに切り出して、コミットメッセージで由来を説明する

再発防止のためには、この経験を .cursor/rules/ や PR テンプレートに書いておく。「過去踏んだ罠」は文書化しておかないと忘れる。

まとめ

String Catalog は間違いなく便利な機能だ。自動抽出がなければ 60d Memo の日英ローカライズはもっと面倒だったと思う。ただ「便利ゆえの自動更新」は、git のタイミングと噛み合わないと 静かな負債になる。1 コマンドだけでだいぶ防げるので、String Catalog を使っているプロジェクトでは一度 git status を眺めてみることをおすすめしたい。