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 のビルド時で、コミットするかどうかは開発者次第。ここにズレが生まれる。
なぜドリフトが蓄積するのか
一連の流れはこうなる。
- 機能ブランチ A で
Text("メモを書き出す")を追加 - Xcode で実行(シミュレータやプレビュー)して動作確認
- その瞬間、
Localizable.xcstringsにキーが追加される - 開発者は Swift のコード変更だけを
git add .(or 個別 add)して PR を出す - xcstrings の変更は 「いつの間に入っていたか分からない」状態で working tree に居座る
- 別の機能ブランチ B に切り替えると xcstrings の変更が一緒について回る
- 最終的に 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 に切り出すか、捨てるかを判断する。
判断基準はシンプルだ。
- この PR で新しい
Text(...)を追加したか? → Yes なら xcstrings も一緒にコミット。No なら 捨てる(git checkout HEAD -- 60dMemo/Localizable.xcstrings)。 - 既存の文字列をリネームしたか? → Yes なら xcstrings の旧キー削除 + 新キー追加が正しい差分。
- 何も触った記憶がないが差分がある → Xcode の意図しない更新なので捨てる。
このチェックを .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 に紛れ込ませてしまった場合は、焦らず追う。
git log -p -- <xcstrings> | lessで履歴を時系列に追う- 各追加キーの 初出コミット と 本来の機能実装コミットを突き合わせる
- 両者が一致していなければドリフト。本来の PR に cherry-pick し直すか、release notes でまとめて言及するかを決める
- ひどい場合は、release prep PR 内で xcstrings だけ別コミットに切り出して、コミットメッセージで由来を説明する
再発防止のためには、この経験を .cursor/rules/ や PR テンプレートに書いておく。「過去踏んだ罠」は文書化しておかないと忘れる。
まとめ
- Xcode の String Catalog は
Text(...)の追加で ビルド時に自動で xcstrings を更新する。コミットは開発者次第なのでドリフトが起きる。 - ドリフトはサイレントで、レビューでも気付きにくく、発覚したときには git blame が嘘をつく状態になっている。
- 予防の本丸は PR マージ前の 1 コマンド:
git status --short 60dMemo/Localizable.xcstrings。 - pre-commit hook で完全自動化は難しい。止めない警告と 運用ルール化 の組み合わせが現実的。
- AI エージェント運用なら
.cursor/rules/github-workflow.mdcに条文として書いておくと守られやすい。
String Catalog は間違いなく便利な機能だ。自動抽出がなければ 60d Memo の日英ローカライズはもっと面倒だったと思う。ただ「便利ゆえの自動更新」は、git のタイミングと噛み合わないと 静かな負債になる。1 コマンドだけでだいぶ防げるので、String Catalog を使っているプロジェクトでは一度 git status を眺めてみることをおすすめしたい。