はじめに

日本語のファイル名を Git で管理していて、こんな症状に出会ったことはありませんか。

  • コミット済みのはずのファイルが、git status で「未追跡(??)」になる(VS Code でも未追跡扱いになる)
  • Mac で clone した直後から、誰も編集していないのに消えない差分が出る
  • Codespaces では何ともないのに、手元の Mac でだけ再現する

原因は Unicode 正規化(NFC/NFD)のゆれです。同じ「データ.txt」でも、濁点が 1 文字に合成された NFC と、分解された NFD の 2 通りがあります。
macOS の標準 API を通して保存すると NFD になることがあり、Git はこの 2 つを別のファイルとして扱います。

macOS のローカルと GitHub Codespaces(=Linux コンテナ)を行き来する構成で、この問題を何度も踏みました。確実なのは「ファイル名を英数字にする」ことですが、原本の維持や仕様の都合でリネームできない現場も多いはずです。本記事では、日本語ファイル名のまま共存するための検出と修復を紹介します。

検証環境: macOS 26.6.2 / Git 2.48.1 / Python 3.14.2

1. 何が起きているのか

そもそも NFC / NFD とは — 「ば」は1文字ではない(こともある)

Unicode では、濁点・半濁点つきのかな文字を2通りの方法で表現できます。

表現方法 「ば」の中身 コードポイント
NFC(合成済み) 「ば」1文字 U+3070
NFD(分解済み) 「は」+「゛(結合濁点)」の2文字 U+306F U+3099

どちらも画面上の見た目は「ば」ですが、バイト列としては別物です。
影響を受けるのは主に濁点・半濁点を含むかな(が・ば・ぱ・ヴ など)です。

NFD のファイル名はどこから来るのか

NFC と NFD のどちらになるかは OS だけでは決まらず、その名前を付けた経路で決まります。

形式 名前を付けた経路
NFC ターミナルやスクリプトでの作成、Windows / Linux での入力
NFD Mac の Finder や多くの Mac アプリ、HFS+ 時代のデータ

なぜ Git で問題になるのか

保存の仕方と、NFC / NFD の扱い方は OS によって違います。

OS(ファイルシステム) 保存時の挙動 NFC と NFD の違いをどう扱うか
macOS(APFS) 渡されたまま保存 同じファイルとみなす(よしなに探してくれる)
Windows(NTFS) 渡されたまま保存 完全に別のファイルとして厳密に区別する
Linux(ext4 など) 渡されたまま保存 完全に別のファイルとして厳密に区別する

ここで注意したいのが、macOS の「同じファイルとみなす」挙動です。
どちらで保存されていても同じファイルに見えるため、ファイルを開いたりコピーしたりしているかぎり違いには気づけません。

ところが Git は、ファイルパスをバイト列として比較します。

見た目が同じでもバイト列が違えば別のファイルです。
つまり NFC の「データ.txt」と NFD の「データ.txt」を、macOS は同じファイル、Git は別のファイルと判断します。この食い違いから、3 つの症状が出ます。

症状1: 追跡済みのファイルが「未追跡」と表示される

インデックスには NFD で登録されているのに、macOS の Git はディスク上の名前を読むとき NFC へ直します(2章で触れる core.precomposeUnicode の働きです)。その結果、両者が一致せず、別のファイルとして扱われます。

同じ 1 つのファイルが、Git の中では追跡済みの NFD と未追跡の NFC に分裂します。

$ git -c core.quotePath=false status --short   # 既定では 8 進数エスケープ表示になるため、日本語で出す指定
?? データ.txt

?? は「未追跡」の印です。ファイルは目の前にあるのに、Git からは「知らないファイル」として扱われます。

この状態はブランチ操作にも波及します。片方のブランチだけ NFC に直したあと、NFD のままのブランチへ移ろうとすると、未追跡ファイルの上書き防止が働き untracked working tree files would be overwritten で拒否されます。

症状2: ファイル名を指定するコマンドが通らない

画面に表示されている名前をそのまま打っても、インデックスの NFD には一致しません。

$ git checkout -- "データ.txt"
error: pathspec 'データ.txt' did not match any file(s) known to git

打ち直してもコピーしても同じです。macOS の Git は引数も NFC に直すため、3章で使う設定でその変換を切らない限り、NFD のパスを指定する手段がありません。
git add や git rm も同様です。

一方 Linux ではインデックスとディスクがどちらも NFD で一致するため、Codespaces 上ではこの症状が出ません。
そのため「自分の Mac だけ壊れている」ように見えます。

症状3: NFC 版が別に登録されると、二重登録になる

上の状態で同じファイルを macOS 側で git add すると、NFC のパスが別の登録として追加されます。

$ git ls-files
"\343\203\206\343\202\231\343\203\274\343\202\277.txt"  ← NFD(テ + 濁点)
"\343\203\207\343\203\274\343\202\277.txt"          ← NFC(デ)

$ git -c core.quotePath=false ls-files   # 日本語で表示する
データ.txt
データ.txt

8 進数エスケープなら見分けられますが読めず、日本語表示ではまったく同じ 2 行になります。
この状態を Mac で clone すると、作業ツリーには片方しか置けません。
2 つの中身が違えば、誰も編集していないのに消えない差分が残ります。中身が同じなら差分は出ませんが、どちらの場合も clone 時に警告が出ます。
これが出たら重複を疑ってください。

warning: the following paths have collided (e.g. case-sensitive paths
on a case-insensitive filesystem) and only one from the same
colliding group is in the working tree:

いずれもファイル名の見た目からは原因が分からない点が共通しています。対象の特定には検出スクリプトが要ります。

2. 防げなかった理由と Git の仕様

前提: 安全網はすでにあり、たいてい有効になっている

macOS 版の Git には NFD を NFC に自動変換する core.precomposeUnicode があり、git init / git clone 時に自動で有効化されます。確認はこれだけです。

git config --get core.precomposeUnicode # => true なら有効

true でなければ git config core.precomposeUnicode true で有効になります。

それでも混入は防げませんでした。理由は 2 つです。

  1. すでに commit された NFD パスは直せない(新規追加分のみが対象)
  2. Linux 版の Git では設定が無視される(機能自体が存在しない)

Linux 環境からの git add による NFD の混入

私が直面したのは 2 つ目の理由でした。設定が有効なのに混入する原因は、Mac 以外の環境で git add を実行したことです。

core.precomposeUnicode は macOS 版の Git だけが持つ独自機能で、ディスクから読んだ名前と、コマンドラインで渡した引数の両方を NFC に直します。同じファイルでも、git add をどこで実行したかで登録される名前が変わります。

手元の NFD の資料を VS Code へドラッグ&ドロップするなどして Codespaces に持ち込み、そちらで git add した場合がこれにあたります。Linux 版の Git には変換機能がないため NFD のまま登録され、あとから Mac で commit しても直りません。

Mac 側の設定だけでは防げないので、3章で検出と修復を行います。

3. どう対処するか

やることは 1 つ、インデックスの NFD のパスを NFC で登録し直すだけです。中身には触れません。全体の流れはこのとおりです。

作業はMac 側のリポジトリ直下で行います。Linux 版の Git には変換機能そのものがないため、Linux で実行すると add 側が NFC に直らず、NFD の登録を消すだけになります。サブディレクトリから実行した場合も、git ls-files がそこ以下しか見ないため対象を取りこぼします。

cd "$(git rev-parse --show-toplevel)"

対策1(検出): 該当するファイルを調べる

判定スクリプトです。/tmp/nfd-scan.py として保存してください。修復でも使い回します。

import sys, unicodedata

only = sys.argv[1] if len(sys.argv) > 1 else None
paths = [p.decode() for p in sys.stdin.buffer.read().split(b"\0") if p]
known = set(paths)
hits = 0
for p in paths:
    if unicodedata.is_normalized("NFC", p):
        continue
    hits += 1
    label = "DUP" if unicodedata.normalize("NFC", p) in known else "NFD"
    if only is None:
        print(label, p)
    elif label == only:
        sys.stdout.buffer.write(p.encode() + b"\0")
sys.exit(1 if hits else 0)

追跡中のパスを流し込みます。
何も出力されなければ問題ないです(-z は区切りを NUL にする指定で、8 進数エスケープを避け、改行を含む名前でも壊れません)。

$ git ls-files -z | python3 /tmp/nfd-scan.py
NFD 資料フォルダ/グラフ.txt
DUP データ 一覧.txt
  • NFD — NFD のパスだけが登録されている → 対策2 へ
  • DUP — NFC 版も別に登録されている(症状3)→ 対策3 へ

NFD または DUP を引数に渡すと、そのラベルのパスだけを出力します。修復ではこれを使います。

対策2(修復): NFD のパスを NFC に置き換える

仕組みは、NFD のパスを NFC へ変換して追加することです。
core.precomposeUnicode=true を付けた git add は、受け取ったパスを NFC に直してからインデックスに登録します。
これで NFC の登録ができるので、あとは元の NFD の登録を消せば付け替えが完了します。
削除する側は変換されると困るので、こちらは false にします。

操作 コマンド 結果
追加 git -c core.precomposeUnicode=true add "$p" 引数が NFC に変換され、NFC のパスが登録される
削除 git -c core.precomposeUnicode=false rm --cached "$p" 引数は変換されず、インデックスの NFD だけが消える

リポジトリ設定に左右されないよう、-c のフラグはどちらも省略しないでください。
rm の --cached は「インデックスから外すだけ」の指定なので、手元のファイルは消えません。

意図しない変更の混入を防ぐため、未コミットの変更がないクリーンな状態で実行してください。
検出結果の NFD を 1 件ずつ付け替えます(DUP は拾いません)。
add が失敗した場合は、誤って追跡から外れるのを防ぐためそこで止まります。

git ls-files -z | python3 /tmp/nfd-scan.py NFD | while IFS= read -r -d '' p; do
    git -c core.precomposeUnicode=true add "$p" || { echo "FAILED: $p"; break; }
    git -c core.precomposeUnicode=false rm --cached "$p" >/dev/null
done

変更内容を確認します。
追加も削除も 0 行なら、中身は変わらずパスの付け替えだけが起きているので、そのままコミットします。
0 でない数字が出たら commit せず、git reset で戻してください。

$ git -c core.quotePath=false diff --cached --stat
 資料フォルダ/グラフ.txt => 資料フォルダ/グラフ.txt | 0
 1 file changed, 0 insertions(+), 0 deletions(-)

$ git commit -m "fix: ファイル名のNFD追跡パスをNFCへ正規化"

矢印の左右が同じ名前に見えるのは、NFD を NFC に付け替えたためです。

最後に対策1 を再実行し、NFD が消えていれば完了です。なお直るのはインデックスだけで、ディスク上のファイル名は NFD のままです。実害は出ませんが、NFD の資料をあらためて持ち込めば再発します。

対策3(重複ケース): 残す中身を決めてから直す

直し方は対策2 と同じ 2 行ですが、作業ツリーのファイルは 1 つだけなので、そのまま実行するとその中身が採用され、もう一方は失われます。先に、消えるほうを確認します。show にフラグを付けないと逆側(NFC 側)を読んでしまうので注意してください。

IFS= read -r -d '' p < <(git ls-files -z | python3 /tmp/nfd-scan.py DUP)

# 差分が出なければ、消える NFD 側は手元のファイルと同じ中身
git -c core.precomposeUnicode=false show "HEAD:$p" | diff - "$p"

NFD 側を残すと決めたときだけ、先に書き戻します。あとは対策2 と同じ 2 行です。

git -c core.precomposeUnicode=false show "HEAD:$p" > "$p"   # NFD 側を残す場合のみ
git -c core.precomposeUnicode=true add "$p"
git -c core.precomposeUnicode=false rm --cached "$p"

git -c core.quotePath=false diff --cached --stat
git commit -m "fix: 重複していたNFDエントリを削除しNFCへ統一"

対策2 と違い、ここでは 0 になりません。 重複していた登録が 1 件消えるためで、中身を入れ替えた場合はその分の変更も出ます。これが正常なので、reset せずそのまま commit してください。

DUP が複数あるときは、対策1 を実行すると次の 1 件が出てくるので繰り返します。

まとめ

  • 見た目は同じ「ば」でも、NFC と NFD という 2 つの正規化形式が存在する
  • macOS 由来の NFD がインデックスに入ると、追跡済みのファイルが「未追跡」に見え、ファイル名を指定するコマンドも通らなくなる
  • core.precomposeUnicode は macOS 版 Git だけの機能。Codespaces を併用すると、設定が有効でも混入は防げない
  • 混入したら検出スクリプトで見つけ、add と rm --cached でインデックスのパスを NFC に付け替える