Сюрпризы c case-insensitive файловыми системами
Недавно я столкнулся с интересной проблемой и получил еще один ценный урок. Проблема была связана с особенностью файловой системы MacOS, которая по умолчанию case-insensitive (как и в Windows). И комбинация MacOS с Git у нас в проекте привела к неожиданным сложностям.
Case-insensitive файловая система на практике
Имя файла может включать буквы как в верхнем регистре так и в нижнем,
т.е. регистр сохраняется. Но внутри файловой системы регистр букв в
имени и пути файла не учитывается, т.е. можно обратиться к файлу about.md
и как к About.md
и ABout.md
и ABOut.md
.
$ ls -la
total 8
drwxr-xr-x 3 andrykonchin staff 96 Nov 4 22:01 .
drwxr-xr-x 7 andrykonchin staff 224 Nov 4 21:59 ..
-rw-r--r-- 1 andrykonchin staff 442 Nov 4 23:36 about.md
$ ls -la about.md
-rw-r--r-- 1 andrykonchin staff 442 Nov 4 23:36 about.md
$ ls -la About.md
-rw-r--r-- 1 andrykonchin staff 442 Nov 4 23:36 About.md
$ ls -la ABout.md
-rw-r--r-- 1 andrykonchin staff 442 Nov 4 23:36 ABout.md
$ ls -la ABOut.md
-rw-r--r-- 1 andrykonchin staff 442 Nov 4 23:36 ABOut.md
Если в MacOS мы скопируем директорию из какой-то внешней case-sensitive файловой системы (из примонтированной флешка с FAT32, из расшаренной по сети папки итд), то мы можем столкнуться с сюрпризами.
Если в директории было два файла, у которых имена отличаются только регистром букв, то в MacOS лишь один из файлов будет доступен. А вот две такие директории просто сольются в одну.
Git и case-insensitivity
В Git для поддержки case-insensitive файловых систем есть
конфигурационная опция core.ignoreCase
. Если она включена, то Git
будет игнорировать регистр букв в именах и путях файлов при сравнении.
Например, если переименовать файл Gemfile
в gemfile
, то Git не
посчитает это изменением.
$ mv Gemfile gemfile
$ git status
On branch master
No commits yet
nothing to commit (create/copy files and use "git add" to track)
А если отредактировать файл и посмотреть статус репозитория, то Git
выведет, что изменился файл Gemfile
. То есть выведет имя файла до
переименования, которое все еще хранится в Git-репозитории.
$ echo foo > gemfile
$ git status
On branch master
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: Gemfile
no changes added to commit (use "git add" and/or "git commit -a")
И в чем же здесь проблема?
Один из способов наступить на грабли - это создать и закоммитить в Git на Linux (где обычно файловые системы case-sensitive) два файла, имена которых отличаются только регистром букв. На MacOS будет виден только один из них.
Есть и более изощренный вариант. На MacOS отредактировать и затем
переименовать файл изменив только регистр букв и закоммитить в Git. Если
опция Git core.ignoreCase
отключена, то локально будет видна только
последняя отредактированная версия файла, а в Git буде будут закоммичены
оба файла. Если кто-то на Linux заберет эти обновления из Git, то у него
будет два разных файла. Если заберет кто-то на MacOS, то у него будет
виден только один из файлов. Порядок затирания файлов зависит только от
Git, ведь именно он управляет рабочей директорией и копирует туда файлы из
служебной директории .git
.
Возможные решения
Очевидные следующие варианты:
- всем разработчикам в проекте использовать case-sensitive файловые системы (что сразу исключает Windows)
- всем разработчикам на case-insensitive файловых системах убедиться, что Git опция
core.ignoreCase
включена - ничего не менять локально но на CI сервере проверять дубликаты файлов
Последний пункт несложно выполнить в *-nix системах. С этим справится простой однострочник в shell:
git ls-tree -r -t --name-only HEAD . | sort -f | uniq -i -d
В результате мы получим список файлов-дубликатов.
Если на CI используется Linux (с case-insensitive файловой системой), можно заменить команду git
на обычный find
:
find . -type f | sort -f | uniq -i -d
На самом деле от Windows тоже можно добиться поддержки
case-sensitive поведения. В Windows 10 появилась подсистема Windows
Subsystem for Linux (WSL) и теперь можно сделать конкретную директорию
case-sensitive с помощью штатной утилиты fsutil
.
Как мы наткнулись на эти грабли
Мы столкнулись с этой проблемой совершенно случайно. В проекте на CI сервере внезапно начал падать unit-тест. Локально у разработчика он стабильно проходил, а на CI стабильно падал.
Когда начали разбираться, оказалось, что у нас в Git-репозитории задублировался файлик с данными, которые использовались в тесте. Это была кассета для gem‘а VCR в которой были записаны HTTP-запросы/ответы. Файл генерировался автоматически, а имя и путь формировалось на основе названия test case‘а в RSpec. Кто-то переименовал test case изменив только регистр букв и сгенерировался новый файл.
Например, если изменить название с
describe "Domestic services" do
it "returns a rate and services"
end
на
describe "domestic services" do
it "returns a rate and services"
end
то получим два разных пути:
vcr_rspec/.../Domestic_services/returns_a_rate_and_services.yml
vcr_rspec/.../domestic_services/returns_a_rate_and_services.yml
Так как разработчик использовал MacOS, один из файлов перезатер другой и тест локально проходил успешно. Но в Git в итоге были закоммичены оба файла. На CI использовалась Ubuntu и в директории лежали оба файла. В тесте читался не актуальный файл и как следствие тест падал.
Тест мы пофиксили удалив из репозитория файл-дубликат. Но проблема осталась. Очень скоро в Git-репозитории у нас появилось уже 20 таких файлов-дубликатов.
Какие можно сделать выводы?
Разрешите немного покапитанить и сказать очевидные вещи:
- Вы избежите целого класса проблем если среда разработки (операционная система, файловая система, версии системных библиотек итд) будет максимально близка к среде запуска (production, тестовый или CI сервер)
- Если эти среды различаются, важно понимать как и в чем
- И всегда надо помнить, что эти различия рано или поздно выстрелят