FULLALLGOL 技術ノート

macOS では、同じ 1 ファイルが git の index に 2 件ぶら下がる

2026-08-31

日本語のファイル名を含むリポジトリを棚卸ししたら、index に NFD と NFC の 2 エントリとして
載っているファイルが 4 件見つかった。うち 3 件は法定 7 年保存が要るディレクトリの配下だった。

実体は 1 ファイルしかない。それでも index には 2 件ある。

なぜ目視で気づけないのか

露出するのは、正規化を区別する側に渡ったときだ。
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

直した直後に、まだ直っていないブランチへ戻ると 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 行に分裂させて表示した。この装置は「見落とさないこと」だけが存在理由なので、
分裂は安全機構そのものを壊す

しかも割れるのは濁点・半濁点を含むキーだけだ。含まない名前は割れない。
つまり一部だけ壊れて、全体は正常に見える

tmp = p.with_name("__nfc_tmp__")
p.rename(tmp)
tmp.rename(p.with_name(unicodedata.normalize("NFC", p.name)))

assert not dst.exists() を安全確認のつもりで置くと、正しい rename を誤って止める
このアサート自体が間違っている、という珍しい型だ。

まとめ