Глава 9. Раскрываем тайны

Мы заглянем под капот и объясним, как Git творит свои чудеса. Я опущу излишние детали. За более детальными описаниями обратитесь к руководству пользователя.

Невидимость

Как Git может быть таким ненавязчивым? За исключением периодических коммитов и слияний, вы можете работать так, как будто и не подозреваете о каком-то управлении версиями. Так происходит до того момента, когда Git вам понадобится, и тогда вы с радостью увидите, что он наблюдал за вами все это время.

Другие системы управления версиями вынуждают вас постоянно бороться с загородками и бюрократией. Файлы могут быть доступны только для чтения, пока вы явно не укажете центральному серверу, какие файлы вы намереваетесь редактировать. С увеличением количества пользователей большинство базовых команд начинают выполняться всё медленнее. Неполадки с сетью или с центральным сервером полностью останавливают работу.

В противоположность этому, Git просто хранит историю проекта в подкаталоге .git вашего рабочего каталога. Это ваша личная копия истории, поэтому вы можете оставаться вне сети, пока не захотите взаимодействовать с остальными. У вас есть полный контроль над судьбой ваших файлов, поскольку Git в любое время может легко восстановить сохраненное состояние из .git.

Целостность

Большинство людей ассоциируют криптографию с содержанием информации в секрете, но другой столь же важной задачей является содержание ее в сохранности. Правильное использование криптографических хеш-функций может предотвратить случайное или злонамеренное повреждение данных.

SHA1 хеш можно рассматривать как уникальный 160-битный идентификатор для каждой строки байт, с которой вы сталкиваетесь в вашей жизни. Даже больше того: для каждой строки байтов, которую любой человек когда-либо будет использовать в течение многих жизней.

Так как SHA1 хеш сам является последовательностью байтов, мы можем получить хеш строки байтов, содержащей другие хеши. Это простое наблюдение на удивление полезно: ищите «hash chains» (цепочки хешей). Позднее мы увидим, как Git использует их для эффективного обеспечения целостности данных.

Говоря кратко, Git хранит ваши данные в подкаталоге ".git/objects", где вместо нормальных имен файлов вы найдете только идентификаторы. Благодаря использованию идентификаторов в качестве имен файлов, а также некоторым хитростям с файлами блокировок и временны́ми метками, Git преобразует любую скромную файловую систему в эффективную и надежную базу данных.

Интеллект

Как Git узнаёт, что вы переименовали файл, даже если вы никогда не упоминали об этом явно? Конечно, вы можете запустить git mv; но это то же самое, что git rm, а затем git add.

Git эвристически находит файлы, которые были переименованы или скопированы между соседними версиями. На деле он может обнаружить, что участки кода были перемещены или скопированы между файлами! Хотя Git не может охватить все случаи, он всё же делает достойную работу, и эта функция постоянно улучшается. Если она не сработала, попробуйте опции, включающие более ресурсоемкое обнаружение копирования и подумайте об обновлении.

Индексация

Для каждого отслеживаемого файла, Git записывает такую информацию, как размер, время создания и время последнего изменения, в файле, известном как «индекс». Чтобы определить, был ли файл изменен, Git сравнивает его текущие характеристики с сохраненными в индексе. Если они совпадают, то Git не станет перечитывать файл заново.

Поскольку считывание этой информации значительно быстрее, чем чтение всего файла, то если вы редактировали лишь несколько файлов, Git может обновить свой индекс почти мгновенно.

Мы отмечали ранее, что индекс это буферная зона. Почему набор свойств файлов выступает таким буфером? Потому что команда add помещает файлы в базу данных Git и в соответствии с этим обновляет эти свойства; тогда как команда commit без опций создает коммит, основанный только на этих свойствах и файлах, которые уже в базе данных.

Происхождение Git

Это сообщение в почтовой рассылке ядра Linux описывает последовательность событий, которые привели к появлению Git. Весь этот тред — привлекательный археологический раскоп для историков Git.

База данных объектов

Каждая версия ваших данных хранится в «базе данных объектов», живущей в подкаталоге .git/objects. Другие «жители» .git/ содержат вторичные данные: индекс, имена веток, теги, параметры настройки, журналы, нынешнее расположение «головного» коммита и так далее. База объектов проста и элегантна, и в ней источник силы Git.

Каждый файл внутри .git/objects это «объект». Нас интересуют три типа объектов: объекты «блобов», объекты деревьев и объекты коммитов.

Блобы

Для начала один фокус. Выберите имя файла — любое имя файла. В пустом каталоге:

$ echo sweet > ВАШЕ_ИМЯ_ФАЙЛА
$ git init
$ git add .
$ find .git/objects -type f

Вы увидите .git/objects/aa/823728ea7d592acc69b36875a482cdf3fd5c8d.

Откуда я знаю это, не зная имени файла? Это потому, что SHA1 хеш строки

«blob» SP «6» NUL «sweet» LF

равен aa823728ea7d592acc69b36875a482cdf3fd5c8d, где SP это пробел, NUL — нулевой байт и LF — перевод строки. Вы можете проверить это, набрав

$ printf "blob 6\000sweet\n" | sha1sum

Git использует «адресацию по содержимому»: файлы хранятся в соответствии не с именами, а с хешами содержимого, — в файле, который мы называем «блоб-объектом». Хеш можно понимать как уникальный идентификатор содержимого файла, что означает обращение к файлам по их содержимому. Начальный «blob 6» — лишь заголовок, состоящий из типа объекта и его длины в байтах и упрощающий внутренний учет.

Таким образом, я могу легко предсказать, что вы увидите. Имя файла не имеет значения: для создания блоб-объекта используется только его содержимое.

Вам может быть интересно, что происходит с одинаковыми файлами. Попробуйте добавить копии своего файла с какими угодно именами. Содержание .git/objects останется тем же независимо от того, сколько копий вы добавите. Git хранит данные лишь единожды.

Кстати, файлы в каталоге .git/objects сжимаются с помощью zlib поэтому вы не сможете просмотреть их напрямую. Пропустите их через фильтр zpipe -d, или введите

$ git cat-file -p aa823728ea7d592acc69b36875a482cdf3fd5c8d

что выведет указанный объект в читаемом виде.

Деревья

Но где же имена файлов? Они должны храниться на каком-то уровне. Git обращается за именами во время коммита:

$ git commit  # Введите какое-нибудь описание
$ find .git/objects -type f

Теперь вы должны увидеть три объекта. На этот раз я не могу сказать вам, что из себя представляют два новых файла, так как это частично зависит от выбранного вами имени файла. Далее будем предполагать, что вы назвали его «rose». Если это не так, то вы можете переписать историю, чтобы она выглядела как будто вы это сделали:

$ git filter-branch --tree-filter 'mv ВАШЕ_ИМЯ_ФАЙЛА rose'
$ find .git/objects -type f

Теперь вы должны увидеть файл .git/objects/05/b217bb859794d08bb9e4f7f04cbda4b207fbe9, так как это SHA1 хеш его содержимого:

«tree» SP «32» NUL «100644 rose» NUL 0xaa823728ea7d592acc69b36875a482cdf3fd5c8d

Проверьте, что этот файл действительно содержит указанную строку, набрав

$ echo 05b217bb859794d08bb9e4f7f04cbda4b207fbe9 | git cat-file --batch

С zpipe легко проверить хеш:

$ zpipe -d < .git/objects/05/b217bb859794d08bb9e4f7f04cbda4b207fbe9 | sha1sum

Проверка хеша с помощью cat-file сложнее, поскольку ее вывод содержит не только «сырой» распакованный файл объекта.

Этот файл — объект «дерево» (tree, прим. пер.): список цепочек, состоящих из типа, имени файла и его хеша. В нашем примере: тип файла — 100644, что означает, что «rose» это обычный файл; а хеш — блоб-объект, в котором находится содержимое «rose». Другие возможные типы файлов: исполняемые файлы, символические ссылки или каталоги. В последнем случае, хеш указывает на объект «дерево».

Если вы запускали filter-branch, у вас есть старые объекты которые вам больше не нужны. Хотя по окончании срока хранения они будут выброшены автоматически, мы удалим их сейчас, чтобы было легче следить за нашим игрушечным примером:

$ rm -r .git/refs/original
$ git reflog expire --expire=now --all
$ git prune

Для реальных проектов обычно лучше избегать таких команд, поскольку вы уничтожаете резервные копии. Если вы хотите иметь чистое хранилище, то обычно лучше сделать свежий клон. Кроме того, будьте осторожны при непосредственном вмешательстве в каталог .git: что если другая команда Git работает в это же время, или внезапно произойдет отключение питания? Вообще говоря, ссылки нужно удалять с помощью git update-ref -d, хотя обычно ручное удаление refs/original безопасно.

Коммиты

Мы рассмотрели два из трех объектов. Третий объект — «коммит» (commit). Его содержимое зависит от описания коммита, как и от даты и времени его создания. Для соответстия тому, что мы имеем, мы должны немного «подкрутить» Git:

$ git commit --amend -m Shakespeare  # Изменим описание коммита.
$ git filter-branch --env-filter 'export
    GIT_AUTHOR_DATE="Fri 13 Feb 2009 15:31:30 -0800"
    GIT_AUTHOR_NAME="Alice"
    GIT_AUTHOR_EMAIL="alice@example.com"
    GIT_COMMITTER_DATE="Fri, 13 Feb 2009 15:31:30 -0800"
    GIT_COMMITTER_NAME="Bob"
    GIT_COMMITTER_EMAIL="bob@example.com"'  # Подделаем временные метки и авторов.
$ find .git/objects -type f

Теперь вы должны увидеть .git/objects/49/993fe130c4b3bf24857a15d7969c396b7bc187 который является SHA1 хешем его содержимого:

«commit 158» NUL
«tree 05b217bb859794d08bb9e4f7f04cbda4b207fbe9» LF
«author Alice <alice@example.com> 1234567890 -0800» LF
«committer Bob <bob@example.com> 1234567890 -0800» LF
LF
«Shakespeare» LF

Как и раньше, вы сами можете запустить zpipe или cat-file, чтобы увидить это.

Это первый коммит, поэтому здесь нет родительских коммитов, но последующие коммиты всегда будет содержать хотя бы одну строку, идентифицирующую родительский коммит.

Неотличимо от волшебства

Секреты Git выглядят слишком простыми. Похоже, что вы могли бы объединить несколько shell-скриптов и добавить немного кода на C, чтобы сделать всё это в считанные часы: смесь базовых операций с файлами и SHA1-хеширования, приправленная блокировочными файлами и fsync для надеждности. По сути, это точное описание ранних версий Git. Тем не менее, помимо гениальных трюков с упаковкой для экономии места и с индексацией для экономии времени, мы теперь знаем, как ловко Git преображает файловую систему в базу данных, идеально подходящую для управления версиями.

Например, если какой-либо файл в базе данных объектов поврежден из-за ошибки диска, то его хеш теперь не совпадет, что привлечет наше внимание к проблеме. С помощью хеширования хешей других объектов, мы поддерживаем целостность на всех уровнях. Коммиты атомарны, так что в них никогда нельзя записать лишь часть изменений: мы можем вычислить хеш коммита и сохранить его в базу данных только сохранив все соответствующие деревья, блобы и родительские коммиты. База данных объектов нечувствительна к непредвиденным прерываниям работы, таких как перебои с питанием.

Мы наносим поражение даже самым хитрым противникам. Предположим, кто-то пытается тайно изменить содержимое файла в древней версии проекта. Чтобы база объектов выглядела неповрежденной, он также должен изменить хеш соответствующего блоб-объекта, поскольку это теперь другая последовательность байтов. Это означает, что нужно поменять хеши всех объектов деревьев, ссылающихся на этот файл; что в свою очередь изменит хеши всех объектов коммитов с участием таких деревьев; а также и хеши всех потомков этих коммитов. Вследствие этого хеш официальной головной ревизии будет отличаться от аналогичного хеша в этом испорченном хранилище. По цепочке несовпадающих хешей мы можем точно вычислить искаженный файл, как и коммит, где он изначально был поврежден.

Одним словом, невозможно подделать хранилище Git, оставив невредимыми двадцать байт, отвечающие последнему коммиту.

Как насчет известных характерных особенностей Git? Ветвление? Слияние? Теги? Очевидные подробности. Текущая «голова» хранится в файле .git/HEAD, содержащем хеш объекта коммита. Хеш обновляется во время коммита, а также при выполнении многих других команд. С ветками всё аналогично: это файлы в .git/refs/heads. То же и тегами: они живут в .git/refs/tags, но их обновляет другой набор команд.