Сюрпризы 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 сервер)
  • Если эти среды различаются, важно понимать как и в чем
  • И всегда надо помнить, что эти различия рано или поздно выстрелят

Полезные ссылки