[Git] 大文字小文字だけのリネームでマージが失敗する原因と対処法

背景

コンポーネント名の命名規則(PascalCase)に合わせて、ファイル名の大文字小文字だけを直すリネームをすることがある。中身は一切変えていないので何の問題もないはずだが、このリネームを取り込むgit mergegit pullが原因不明のエラーで止まることがあった。原因と対処法をまとめる。

環境

  • macOS(ファイルシステムはAPFS。大文字小文字を区別しない設定がデフォルト)
  • Git 2.55.0

状況

userProfile.tsxというファイル名をコンポーネントの命名規則に合わせてUserProfile.tsxにリネームし、別ブランチへマージしようとしたところ、以下のエラーで止まった。

error: The following untracked working tree files would be overwritten by merge:
        UserProfile.tsx
Please move or remove them before you merge.
Aborting

UserProfile.tsxはGitで管理しているファイルのはずなのに、「未追跡ファイル(untracked)」だと言われる。しかも大文字小文字を変えただけであり、lsで見ても「ファイルはちゃんとある」ようにしか見えず、何が起きているのか分かりにくい。

原因

macOSの既定のファイルシステム(APFS)は大文字小文字を区別しない(case-insensitive)。Gitはリポジトリの作成時にこれを検出し、core.ignorecaseを自動でtrueに設定する。公式ドキュメントでは、この設定を次のように説明している。

Internal variable which enables various workarounds to enable Git to work better on filesystems that are not case sensitive, like APFS, HFS+, FAT, NTFS, etc.

つまりcore.ignorecaseは、大文字小文字を区別しないファイルシステムの上でGitをうまく動かすための「回避策」の集合であり、Gitが本来前提とする大文字小文字を区別する世界と、OS側の区別しない世界とのずれを埋め合わせる仕組みである。この埋め合わせは常に完全とは限らない。そのため、大文字小文字だけのリネームをマージで取り込む際に、ワークツリー上の実ファイルとGitの追跡状態にずれが生じる余地を残す。

なお同じエラーメッセージは、大文字小文字のリネームに限らず、ローカルに上流と同名の未追跡ファイルが存在するだけでも発生する(GitHubのDiscussionでも報告されている)。

再現性の確認

対処法をまとめる前に、最小構成で再現するか確認した。

# ブランチを分けて片方だけファイルをリネームし、もう片方には無関係な変更を加えてからマージする
git init -b main repo && cd repo
echo "export const UserProfile = () => null;" > userProfile.tsx
git add userProfile.tsx && git commit -m "add userProfile.tsx"

git branch feature
git checkout feature
git mv userProfile.tsx UserProfile.tsx
git commit -m "rename to UserProfile.tsx"

git checkout main
echo "readme" > README.md
git add README.md && git commit -m "add README"

git merge feature

手元のGit 2.55.0では、この手順は問題なくマージが成功した。ブランチの往復や、merge.autostashを有効にしてコミットしていない変更が残った状態でのmergeも試したが、いずれも再現しなかった。

つまりこのエラーは「別ブランチでリネームしてマージするだけ」では起きず、作業ツリーに何らかの状態(古いcasingの実ファイルが複数ヵ所に残っている、複数回のブランチ切り替えを経ているなど)が重なったときに発生すると考えられる。確実な再現条件は特定できていないため、以降は発生したときの対処法として書く。

対処法

1. 内容差分を確認する

対処の前に、リネーム前後で中身が本当に同じかを必ず確認する。ここを省略すると、リネームだと思っていた変更が実は内容の変更も含んでいた場合にデータを失う。

# マージ先に取り込まれる内容と手元の実ファイルを比較する
git show feature:UserProfile.tsx > /tmp/incoming.tsx
diff /tmp/incoming.tsx UserProfile.tsx

差分がなければ、純粋なcasingの変更だと確認できる。

2. 取り込む側に独自の変更がない場合

いま手元にある実ファイルを一時退避してから、マージし直す。

mv UserProfile.tsx /tmp/UserProfile.tsx.bak
git merge feature

ここでgit pullではなくgit merge <branch>を直接使う。pull.rebasemerge.autostashを有効にしている環境では、git pullが追跡中の変更を自動でstashしてからマージし、完了後に戻す。このstash popは、直前に退避した古いcasingの実ファイルを作業ツリーへ復元してしまうため、同じcasingの衝突を再び招くことがある。

マージがfast-forwardにならず、空のマージコミットだけが余分にできてしまった場合は、以下でブランチを追跡先にそろえ直す。

# ローカルブランチを追跡先のコミットへ付け直す
git checkout -B feature origin/feature

3. 取り込む側にも独自の変更がある場合

取り込む側(マージする側)のブランチ自身に、そのファイルへの変更がすでにある場合は、退避ではなく「先に同じリネームを済ませる」方法を取る。

# 両ブランチのcasingを先に揃えてからマージする
git mv -f userProfile.tsx UserProfile.tsx
git commit -m "fix: casingをコンポーネント名に合わせる"
git merge feature --no-edit

両ブランチのcasingをそろえてからマージすると、Gitは双方を「同じファイルへの変更」として扱えるようになり、casingそのものの競合は起きなくなる。中身の変更がある側は、通常のマージと同じように採用される。

まとめ

  • 原因はcore.ignorecaseが埋め合わせている大文字小文字の差である。同じエラーメッセージ自体は、上流と同名の未追跡ファイルがあるだけでも発生し、GitHub上でも報告がある
  • ただし最小構成では再現せず、発生条件は作業ツリーの状態に依存すると考えられる
  • 発生したら、内容差分の確認 →git mergeの直接実行 → そろわない場合は両ブランチでcasingをそろえてからマージ、の順で対処する

参考