macOS では、同じ 1 ファイルが git の index に 2 件ぶら下がる
日本語のファイル名を含むリポジトリを棚卸ししたら、index に NFD と NFC の 2 エントリとして
載っているファイルが 4 件見つかった。うち 3 件は法定 7 年保存が要るディレクトリの配下だった。
実体は 1 ファイルしかない。それでも index には 2 件ある。
なぜ目視で気づけないのか
- APFS は正規化非依存なので、NFD の名前でも NFC の名前でも同じ実体に解決する
- 両方のエントリが同じ実体を指すため、
git statusはクリーンのまま。差分にも出ない lsでも 1 件しか見えない。macOS 上では原理的に目視できない
露出するのは、正規化を区別する側に渡ったときだ。
Linux の CI、別ボリューム、他 OS からの clone。そこでは 2 ファイルに化け、
片方を消すと残りが「削除済みなのに存在する」という状態になる。
混入源は他 OS 経由での追加や、core.precomposeunicode が効いていない古い環境。
入り口は塞ぎきれないので、定期検査で拾うしかない。
検出は index を正規化して突き合わせる
import subprocess, unicodedata, collections
m = collections.defaultdict(list)
out = subprocess.run(['git', 'ls-files', '-z'], capture_output=True).stdout
for p in out.decode().split('\0'):
if p:
m[unicodedata.normalize('NFC', p)].append(p)
dupes = {k: v for k, v in m.items() if len(v) > 1}
正規化後のキーが同じで、生のパスが 2 通りあるものが該当する。
直し方 ── git rm は使えない
git rm は実体を巻き込む。両方の名前が同じ実体に解決するので、
「working tree に変更がある」と拒否されるか、残したいほうを消す。
触るのは index だけにする。
python3 detect_nfd.py | git update-index --force-remove -z --stdin
- 残すのは NFC 側。git と Linux の標準はこちら
- 実行後に「実体が健在」「NFC エントリが index に残っている」「消えたのは NFD 側」の 3 点を確認する
- ただし
git statusの表示では NFD と NFC が同じに見えるので、目視の確認は意味がない - 検算はコードで行う。
unicodedata.normalizeで form を判定して出力する
直した直後に、まだ直っていないブランチへ戻ると checkout が止まる
error: The following untracked working tree files would be overwritten by checkout:
docs/2023-05-18_請求書.pdf
切替先のブランチは NFD エントリを持っているので、git はそのファイルを作ろうとする。
ところが APFS では既存の実体に解決してしまうため、追跡外ファイルの上書きに見える。
ここで実体を消してはいけない。それは正しいファイルだ。git fetch してから修正後の既定ブランチへ直接飛ぶ(git checkout -B main origin/main)。
古い状態のブランチを経由しないのが要点で、マージ後の後片付けでいきなり出る。
git の外でも起きる ── ツールが組んだ名前は NFD で保存される
index の話だけではない。**書き込む側のツールが濁点・半濁点を含む名前を自分で組み立てると、
APFS には NFD で保存される**。
あるコマンドラインツールが出力ファイル名を引数から組み、同じディレクトリに
別のスクリプトが書いた NFC の名前が並んだ。すると、**ファイル名を文字列でグループ化するコードが
1 件を 2 件に割った**。
実害は地味ではなかった。未処理データの取りこぼしを防ぐための点検スクリプトが、
同じ 1 件を 2 行に分裂させて表示した。この装置は「見落とさないこと」だけが存在理由なので、
分裂は安全機構そのものを壊す。
しかも割れるのは濁点・半濁点を含むキーだけだ。含まない名前は割れない。
つまり一部だけ壊れて、全体は正常に見える。
- 検出は
Path.iterdir()で行う。ls・シェルの glob・一覧をパイプで読む書き方は、途中で正規化され得るのでNFC だと嘘をつく - 直しは一時名を挟んだ 2 段 rename。 直接 rename する前に
dst.exists()で確認するとTrue が返る(正規化非依存の照合で同じ実体に解決するため)
tmp = p.with_name("__nfc_tmp__")
p.rename(tmp)
tmp.rename(p.with_name(unicodedata.normalize("NFC", p.name)))
assert not dst.exists() を安全確認のつもりで置くと、正しい rename を誤って止める。
このアサート自体が間違っている、という珍しい型だ。
まとめ
- 日本語ファイル名は NFD/NFC の二重 index エントリになり得る。
git statusはクリーンのまま潜伏する - 目視では区別できない。検出も検算も
unicodedata.normalizeを使ったコードで行う - 直すのは index だけ。実体には触らない。残すのは NFC 側
- ツールが自分で組んだファイル名も NFD になる。濁点を含むキーだけが割れる=部分的にしか壊れない
- ファイル名の一覧は
Path.iterdir()で取る。シェル経由の一覧は正規化について嘘をつく