背景
コンポーネント名の命名規則(PascalCase)に合わせて、ファイル名の大文字小文字だけを直すリネームをすることがある。中身は一切変えていないので何の問題もないはずだが、このリネームを取り込むgit mergeやgit pullが原因不明のエラーで止まることがあった。原因と対処法をまとめる。
環境
- macOS(ファイルシステムはAPFS。大文字小文字を区別しない設定がデフォルト)
- Git 2.55.0
コンポーネント名の命名規則(PascalCase)に合わせて、ファイル名の大文字小文字だけを直すリネームをすることがある。中身は一切変えていないので何の問題もないはずだが、このリネームを取り込むgit mergeやgit pullが原因不明のエラーで止まることがあった。原因と対処法をまとめる。
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.
AbortingUserProfile.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の実ファイルが複数ヵ所に残っている、複数回のブランチ切り替えを経ているなど)が重なったときに発生すると考えられる。確実な再現条件は特定できていないため、以降は発生したときの対処法として書く。
対処の前に、リネーム前後で中身が本当に同じかを必ず確認する。ここを省略すると、リネームだと思っていた変更が実は内容の変更も含んでいた場合にデータを失う。
# マージ先に取り込まれる内容と手元の実ファイルを比較する
git show feature:UserProfile.tsx > /tmp/incoming.tsx
diff /tmp/incoming.tsx UserProfile.tsx差分がなければ、純粋なcasingの変更だと確認できる。
いま手元にある実ファイルを一時退避してから、マージし直す。
mv UserProfile.tsx /tmp/UserProfile.tsx.bak
git merge featureここでgit pullではなくgit merge <branch>を直接使う。pull.rebaseやmerge.autostashを有効にしている環境では、git pullが追跡中の変更を自動でstashしてからマージし、完了後に戻す。このstash popは、直前に退避した古いcasingの実ファイルを作業ツリーへ復元してしまうため、同じcasingの衝突を再び招くことがある。
マージがfast-forwardにならず、空のマージコミットだけが余分にできてしまった場合は、以下でブランチを追跡先にそろえ直す。
# ローカルブランチを追跡先のコミットへ付け直す
git checkout -B feature origin/feature取り込む側(マージする側)のブランチ自身に、そのファイルへの変更がすでにある場合は、退避ではなく「先に同じリネームを済ませる」方法を取る。
# 両ブランチの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をそろえてからマージ、の順で対処する