Git ファイル名 NFD/NFC問題:Macで濁点が分離して幽霊変更が出る時
Mac で git status を実行したとき、docs/ガイド.md のようなファイルが deleted と表示され、そのすぐ下に Untracked files としてまったく同じ名前のファイルが出てくるなら、それは Git ファイル名の NFD/NFC 衝突です。リポジトリ側は合成済みの Unicode(NFC、「ガ」が 1 つのコードポイント)で名前を保存しているのに、macOS は Git に分解済みの形(NFD、「カ」+結合用の濁点)を渡します。Git はバイト列で比較するので、Git から見ればこれは別のファイルです。Mac 側の対処は設定ひとつ、git config core.precomposeunicode true で、すでに分解済みの名前がコミットされているなら git mv で合成形に直します。以下では、診断を確定させる手順と、スクリプト・サーバー・アップロード処理に NFD を持ち込まない方法を説明します。
なぜ Git は「ガイド.md」を 2 つ見るのか:ファイル名の NFD と NFC
Unicode では、一部の文字を 2 通りの方法で表現できます。「ガ」は 1 つのコードポイント(U+30AC、合成済み)としても、「カ」(U+30AB)の後ろに結合用濁点(U+3099)を続けた 2 コードポイントとしても書けます。前者が NFC、後者が NFD です。画面上の見た目は同じです。「パ」(ハ+結合用半濁点 U+309A)、ドイツ語の「ü」やフランス語の「é」(e+結合用アクセント U+0301)、さらに目立つ例としてハングル(한글が個々の字母に分解される)も同じ仕組みです。いわゆる「Mac のファイル名で濁点が分離する」「ファイル名が文字化けする」と言われている現象の正体はこれです。
Windows、Linux、ほとんどの Web サーバーやエディタは NFC を生成します。macOS は歴史的に NFD を生成してきました。かつての HFS+ はすべてのファイル名を分解形に正規化していましたし、APFS になった今でも多くの macOS の API や Finder は分解済みの名前を返します。Git はパスをまったく正規化しません。作業ツリーから受け取ったバイト列をそのまま保存し、それをインデックスと比較します。
典型的な流れはこうです。
- Linux か Windows を使っている同僚が
docs/ガイド.mdを NFC でコミットする。 - 自分が Mac でクローンする。Git は NFC の名前でファイルを書き出すが、ファイルシステムや Mac 側のツールがそれを NFD で返してくる。
- Git はインデックスの
e3 82 ac(ガ)と作業ツリーのe3 82 ab e3 82 99(カ+゛)を比較する。一致しないので、NFC のファイルを deleted、NFD のファイルを untracked として報告する。
逆方向も起こります。Mac ユーザーが NFD の名前をコミットすると、Linux 側の全員に「2 つ目」のファイルが現れ、GUI で消そうとすると元のファイルまで一緒に消えたり、ソースコード内のパスが NFC で書かれているためにビルドが ガイド.md を見つけられなかったりします。
実際にどちらの形になっているか確認する
推測で動かず、バイト列を見ます。
作業ツリー側(macOS または Linux):
ls docs | xxd
- 出力に
e3 82 acがあれば NFC(「ガ」が 1 コードポイント)。 e3 82 ab e3 82 99なら NFD(「カ」の後に U+3099)。- ラテン文字の例なら、
c3 a9が NFC の「é」、65 cc 81が NFD の「e+´」です。
インデックス側、つまり Git がコミット済みとして持っている名前:
git ls-files -z docs | xxd
両者を見比べます。インデックスが e3 82 ac で、ディレクトリ一覧が e3 82 ab e3 82 99 なら、リポジトリは正常で、分解しているのは自分の Mac です。インデックス自体に e3 82 99 や cc 81 のような並びが含まれていれば、すでに分解済みの名前がコミットされているので修復が必要です。
リポジトリ全体から、コミット済みの NFD 名を一気に探す方法:
git ls-files -z | perl -0ne 'print "$_\n" if /[\x{300}-\x{36f}\x{3099}\x{309a}\x{1100}-\x{11ff}]/'
これで、ラテン文字の結合記号、日本語の結合用濁点・半濁点、ハングルの結合字母を含むパスが表示されます。
Git 側で直す
手順 1:macOS の Git に名前を再合成させる
git config --global core.precomposeunicode true
これを有効にすると、Git はファイルシステムから読み取った NFD の名前を、インデックスとの比較前およびコミット前に NFC へ戻します。新しめの Git は git init や git clone のときにファイルシステムを調べてリポジトリ設定に自動でこれを書き込むことが多く、「マシンによって症状が出たり出なかったりする」のはそのためです。グローバルに設定しておけばその運任せがなくなります。設定後にもう一度 git status を実行すれば、deleted と untracked のペアは消えるはずです。
この設定は Git の macOS ビルドにしかありません。Linux や Windows では何もしませんが、これらの OS は名前を分解しないので問題ありません。
手順 2:すでに NFD でコミットされた名前を修復する
インデックスに分解済みのパスが含まれているなら、NFC に変更します。名前はバイト単位で変わらなければならないので、2 段階でリネームします(大文字小文字や正規化の違いを区別しないファイルシステムでは、Git が「同じ名前」と判断するリネームを拒否するためです)。
# パスの NFC 版を作る
nfc=$(printf '%s' 'docs/ガイド.md' | iconv -f utf-8-mac -t utf-8)
git mv 'docs/ガイド.md' docs/tmp-rename
git mv docs/tmp-rename "$nfc"
git commit -m "ファイル名を NFC に正規化"
リテラルのパス部分は、自分の git ls-files 出力に出てきた分解済みのものに置き換えてください(ターミナルからコピーすれば、たいてい分解済みのバイト列が保たれます)。utf-8-mac は macOS の iconv が理解するエンコーディング名です。Linux では次のセクションの Python を使うか、convmv ツールを使います:convmv -f utf-8 -t utf-8 --nfc --notest docs/ガイド.md。
対象が多い場合は、手でリネームせず、上の検出コマンドの出力をループで処理してください。
手順 3:マージで驚かないために
名前が揃ったあとは、.gitattributes では防げません(これが扱うのは改行コードや diff であって、パスの正規化ではありません)。守りになるのは設定と習慣です。Mac を使うメンバー全員が core.precomposeunicode を有効にしておくこと。さらに、上の検出ワンライナーを pre-commit フックで実行し、1 件でも該当すれば失敗させるようにしておけば、OS が混在するチームでも安価な安全網になります。
スクリプト・サーバー・ファイルアップロードで正規化する
Git は問題が目に見える場所にすぎません。同じ NFD の名前は、文字列比較でパスを探すデプロイスクリプト、静的サイトのビルド、アップロード処理も壊します。ルールは単純で、名前がシステムに入ってくる境界で NFC に正規化することです。
Python:
import unicodedata, os
for name in os.listdir("uploads"):
nfc = unicodedata.normalize("NFC", name)
if nfc != name:
os.rename(os.path.join("uploads", name), os.path.join("uploads", nfc))
Node.js(たとえばアップロードハンドラで、ディスクやオブジェクトストレージに書く前に):
const safeName = file.originalname.normalize("NFC");
シェルで Mac から Linux サーバーへ同期するときは、rsync に変換させてサーバーが NFD を見ないようにします。
rsync -av --iconv=utf-8-mac,utf-8 ./site/ user@server:/var/www/site/
Linux 側にすでにあるディレクトリをまとめて直す:
convmv -r -f utf-8 -t utf-8 --nfc --notest /var/www/uploads
知っておくべき点が 2 つあります。まず、S3 のようなオブジェクトストレージはキーを不透明なバイト列として扱うので、Mac からアップロードした 画像データ.jpg と、データベースから参照している 画像データ.jpg が、気づかないうちに別のキーになっていることがあります。キーを生成する前に正規化してください。次に、ブラウザは multipart アップロードのファイル名を正規化しません。サイト上のフォームは、ユーザーの Mac が生成した名前をそのまま受け取ります。確実に捕まえられるのはサーバー側での正規化だけです。
ファイルが自分のツールチェーンの外から来る場合
ここまでのコードは自分のパイプラインをカバーします。しかし、Mac を使っているデザイナーが濁点入りの名前の画像を 300 枚チケットに添付してきたり、韓国の同僚から送られた ZIP の中身が Windows で分離した文字(한글ではなくㅎㅏㄴㄱㅡㄹ)で表示されたり、取引先からの「資料_データ.pdf」が「テ゛ータ」と化けて届いたりする場合には役立ちません。こうしたファイルは、リポジトリや CMS やサーバーに近づける前にリネームしておく必要がありますし、送り主に「NFC で保存してください」と頼んでもまず通じません。本人には違いが見えないからです。
この用途には、無料の FilenameFixer ウェブアプリ がスクリプトなしで変換を引き受けます。ブラウザにファイルをドラッグ&ドロップすると NFD から NFC へまとめてリネームされ、ZIP アーカイブ(200 MB まで)を渡せば中のファイル名を復元できます。貼り付けたテキストの変換もできるので、設定ファイル内のパス一覧を直すときにも使えます。処理はブラウザ内でローカルに行われ、ファイル自体はサーバーにアップロードされないので、クライアントの素材を扱うときにも気を遣わずに済みます。チームが Web フォーム経由でアップロードしているなら、Chrome 拡張機能がアップロード時にファイル名を自動で修正するので、ブラウザが NFD をそのまま素通しする穴を塞げます。
すでに Git の中にあるものについては、core.precomposeunicode と git mv で対処してください。外部ツールで作業ツリーのファイルを Git に知らせずリネームしても、最初と同じ deleted と untracked のペアができるだけです。