After my previous post Tracking renamed files in Git, here’s another entry in my ongoing series “I thought git mv was useless but I was wrong”.
This one’s especially relevant to users on macOS and Windows, where the file system is case-insensitive by default. More precisely, APFS on macOS is case-insensitive but case-preserving by default. That is, A.TXT and a.txt refer to the same file (and these two cannot coexist in the same directory), but the file system records the filename exactly as you entered it.
If you’re on a such a file system and change the case of a filename, Git will not record the new name — unless you use git mv to perform the renaming.
Demo
1. Without git mv (bad)
Note: I tested this on macOS with the default APFS (case-insensitive) file system. You’ll get different results if your file system is case-sensitive.
Let’s create a fresh repository and commit a single file named A.txt:
mkdir testrepo
cd testrepo
git init
echo "Hello" > A.txt
git add .
git commit -m "Create A"
[main (root-commit) 3d73aea] Create A
1 file changed, 1 insertion(+)
create mode 100644 A.txt
Now we rename the file from A.txt to a.txt:
# Rename the file (change case)
# Note: not using `git mv`
mv A.txt a.txt
git status
nothing to commit, working tree clean
That’s interesting. git status says “nothing to commit” because nothing has changed from its perspective. Git is still tracking a file named A.txt, whose contents haven’t changed.
If we now make edits to the file a.txt (aka A.txt; both names refer to the same file), Git tracks this as a change of the existing file, which is still named A.txt in Git’s datastore:
echo "World" > a.txt
git status
Changes not staged for commit:
modified: A.txt
Let’s commit the change:
git add .
git commit -m "Edit A"
[main e86bcb2] Edit A
1 file changed, 1 insertion(+), 1 deletion(-)
Now we’re in a situation where the recorded filenames on the file system and in Git have diverged. A fresh clone of the repository will create the file with its original name A.txt because that’s the spelling Git has recorded:
cd ..
git clone testrepo testrepo-clone
cd testrepo-clone
ls
A.txt
I think this is a real problem. You might assume it’s not an issue as long as all people working with this repo are on case-insensitive file systems, but can you guarantee that? And even if you can, you cannot guarantee that the software you’re writing will only ever be used on case-insensitive file systems.
For instance, if your code loads the file named a.txt from the app’s bundle but the CI step that packages your app for release checked the file out as A.txt, your app will fail for users on case-sensitive file systems. And the reason is that Git has stored a different filename than what you’re using.
You can avoid this by using git mv for renaming, as shown in the second demo below.
2. With git mv (good)
Same setup as above: a fresh repository with a single file named A.txt:
mkdir testrepo2
cd testrepo2
git init
echo "Hello" > A.txt
git add .
git commit -m "Create A"
[main (root-commit) abc2bba] Create A
1 file changed, 1 insertion(+)
create mode 100644 A.txt
We now rename A.txt to a.txt again, but this time we use git mv:
git mv A.txt a.txt
git status
Changes to be committed:
renamed: A.txt -> a.txt
Aha, Git recognizes the rename. This is exactly what we want! We can commit this to record the new filename:
git commit -m "Rename A.txt to a.txt"
[main 42d1974] Rename A.txt to a.txt
1 file changed, 0 insertions(+), 0 deletions(-)
rename A.txt => a.txt (100%)
That’s it. Git and the file system use the same filename, and so will any new clone of the repository. Future bugs avoided.
Workaround: 2-stage commit
I said in my previous post that it’s not always practical to use git mv for renaming. What to do in this case?
My workaround is to split the rename operation into 2 renamings and commit each separately:
- Rename
A.txtto an arbitrary temporary name, e.g.a_.txt. This filename must differ from the original filename in more than just case. Commit this as “Rename A.txt to a.txt (step 1/2)”. - Rename
a_.txtto the final namea.txt. Commit this as “Rename A.txt to a.txt (step 2/2)”.
By using an intermediate filename that differs in more than just case, we force Git to record the renamings. It looks a little clunky in the commit log, but I’ll take that over introducing a hidden bug.