28 Mar 2014

Git. Просто Git. Лекция 5. Git изнутри.

Git изнутри

В данной лекции рассматриваются внутренние процессы Git’а и особенности его реализации. На мой взгляд, изучение этих вещей — это основа понимания того, насколько Git полезный и мощный инструмент. Хотя некоторые утверждают, что изложение этого материала может сбить новичков с толку и оказаться для них неоправданно сложным. Именно поэтому эта лекция отнесена в конец, давая возможность заинтересованным освоить её раньше, а сомневающимся — позже.

Итак, приступим. Во-первых, напомню, что Git — это, по сути, контентно-адресуемая файловая система с пользовательским СКВ-интерфейсом поверх неё. Довольно скоро станет понятнее, что это значит.

На заре развития Git’а (примерно до версии 1.5), интерфейс был значительно сложнее, поскольку был более похож на интерфейс доступа к файловой системе, чем на законченную СКВ. За последние годы, интерфейс значительно улучшился и по удобству не уступает аналогам; у некоторых, тем не менее, с тех пор сохранился стереотип о том, что интерфейс у Git’а чересчур сложный и труден для изучения.

Контентно-адресуемая файловая система — основа Git’а, очень интересна, именно её мы сначала рассмотрим в этой лекции; далее будут рассмотрены транспортные механизмы и инструменты обслуживания репозитория, с которыми вам в своё время, возможно, придётся столкнуться.

Сантехника и фарфор

В этой книге было описано, как пользоваться Git’ом, применяя примерно три десятка команд, например, checkout, branch, remote и т.п. Но так как сначала Git был скорее инструментарием для создания СКВ, чем СКВ, удобной для пользователей, в нём полно команд, выполняющих низкоуровневые операции, которые спроектированы так, чтобы их можно было использовать в цепочку в стиле UNIX, а также использовать в сценариях. Эти команды, как правило, называют служебными (“plumbing” — трубопровод), а ориентированные на пользователя называют пользовательскими (“porcelain” — фарфор).

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

Когда вы выполняете git init в новом или существовавшем ранее каталоге, Git создаёт подкаталог .git, в котором располагается почти всё, чем он заправляет. Если требуется выполнить резервное копирование или клонирование репозитория, достаточно скопировать всего лишь один этот каталог, чтобы получить почти всё необходимое. И данная лекция почти полностью посвящена его содержимому. Вот так он выглядит:

$ ls 
HEAD
branches/
config
description
hooks/
index
info/
objects/
refs/

Там могут быть и другие файлы, но непосредственно после git init вы увидите именно это. Каталог branches не используется новыми версиями Git’а, а файл description требуется только программе GitWeb, на них не стоит обращать особого внимания. Файл config содержит настройки проекта, а каталог info — файл с глобальным фильтром, игнорирующим те файлы, которые вы не хотите поместить в .gitignore. В каталоге hooks располагаются клиентские и серверные перехватчики.

Итак, осталось четыре важных элемента: файлы HEAD, index и каталоги objects, refs. Это ключевые элементы хранилища Git’а. В каталоге objects находится, собственно, база данных, в refs — ссылки на объекты коммитов в этой базе (ветки). Файл HEAD указывает на текущую ветку, и в файле index хранится информация индекса. В последующих разделах данные элементы будут рассмотрены более подробно.

Объекты в Git

Git — контентно-адресуемая файловая система. Здорово. Но что это означает? А означает это, что в своей основе Git — простое хранилище ключ-значение. Можно добавить туда любое содержимое, в ответ будет выдан ключ, по которому это содержимое можно извлечь. Для примера, можно воспользоваться служебной командой hash-object, которая добавляет данные в каталог .git и возвращает ключ. Для начала создадим новый Git-репозиторий и убедимся, что каталог objects пуст:

$ mkdir test
$ cd test

$ git init
Initialized empty Git repository in /tmp/test/.git/

$ find .git/objects
.git/objects
.git/objects/info
.git/objects/pack

$ find .git/objects -type f
$

Git проинициализировал каталог objects и создал в нём подкаталоги pack и info, пока без файлов. Теперь добавим кое-какое текстовое содержимое в базу Git’а:

$ echo 'test content' | git hash-object -w --stdin
d670460b4b4aece5915caf5c68d12f560a9fe3e4

Ключ -w команды hash-object указывает, что объект необходимо сохранить, иначе команда просто выведет ключ и всё. Флаг --stdin указывает, что данные необходимо считать со стандартного ввода, в противном случае hash-object ожидает имя файла. Вывод команды — 40-символьная контрольная сумма. Это хеш SHA-1 — контрольная сумма содержимого и заголовка, который будет рассмотрен позднее. Теперь можно увидеть, в каком виде будут сохранены ваши данные:

$ find .git/objects -type f 
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4

В каталоге objects появился файл. Это и есть начальное внутреннее представление данных в Git’е — один файл на единицу хранения с именем, являющимся контрольной суммой содержимого и заголовка. Первые два символа SHA определяют подкаталог файла, остальные 38 — собственно, имя.

Получить обратно содержимое объекта можно командой cat-file. Это своеобразный швейцарский армейский нож для проверки объектов в Git’е. Ключ -p означает автоматическое определение типа содержимого и вывод содержимого на печать в удобном виде:

$ git cat-file -p d670460b4b4aece5915caf5c68d12f560a9fe3e4
test content

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

$ echo 'version 1' > test.txt
$ git hash-object -w test.txt 
83baae61804e65cc73a7201a7252750c76066a30

Теперь изменим файл и сохраним его в базе ещё раз:

$ echo 'version 2' > test.txt
$ git hash-object -w test.txt 
1f7a7a472abf3dd9643fd615f6da379c4acb3e3a

Теперь в базе содержатся две версии файла test.txt, а также самый первый сохранённый объект:

$ find .git/objects -type f 
.git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a
.git/objects/83/baae61804e65cc73a7201a7252750c76066a30
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4

Теперь можно откатить файл к его первой версии:

$ git cat-file -p 83baae61804e65cc73a7201a7252750c76066a30 > test.txt 
$ cat test.txt 
version 1

или второй:

$ git cat-file -p 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a > test.txt 
$ cat test.txt 
version 2

Однако запоминать хеш для каждой версии неудобно, к тому же теряется само имя файла, сохраняется лишь содержимое. Объекты такого типа называют блобами (англ. binary large object). Имея SHA-1 объекта, можно попросить Git показать нам его тип с помощью команды cat-file -t:

$ git cat-file -t 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a
blob

Объекты-деревья

Рассмотрим другой тип объектов Git’а — деревья. Они решают проблему хранения имён файлов, а также позволяют хранить группы файлов вместе. Git хранит данные сходным с файловыми системами UNIX способом, но в немного упрощённом виде. Содержимое хранится в объектах-деревьях и блобах, дерево соответствует записи каталога в ФС, а блоб более или менее соответствует inode или содержимому файла. Объект-дерево может содержать одну и более записей, каждая из которых представляет собой набор из SHA-1 хеша, соответствующего блобу или поддереву, режима доступа к файлу, типа и имени файла. Например, в проекте simplegit последнее дерево выглядит так:

$ git cat-file -p master^{tree}
100644 blob a906cb2a4a904a152e80877d4088654daad0c859      README
100644 blob 8f94139338f9404f26296befa88755fc2598c289      Rakefile
040000 tree 99f1a6d12cb4b6f19c8655fca46c3ecf317074e0      lib

Запись master^{tree} означает объект-дерево, на который указывает последний коммит ветки master. Заметьте, что подкаталог lib — не блоб, а указатель на другое дерево:

$ git cat-file -p 99f1a6d12cb4b6f19c8655fca46c3ecf317074e0
100644 blob 47c6340d6459e05787f644c2447d2595f5d3a54b      simplegit.rb

Схематически, данные, хранящиеся в Git’е, выглядят примерно так, как это изображено на рисунке 9-1.

Insert 18333fig0901.png Рисунок 9-1. Упрощённая модель данных Git’а.

Вручную можно создавать не только блобы, но и деревья. Git обычно создаёт дерево, исходя из состояния индекса, и затем сохраняет соответствующий объект-дерево. Поэтому для создания объекта-дерева необходимо проиндексировать какие-нибудь файлы. Для создания индекса из одной записи — первой версии файла test.txt, воспользуемся командой update-index. Данная команда может искусственно добавить более раннюю версию test.txt в новый индекс. Необходимо передать опции --add, т.к. файл ещё не существует в индексе (да и самого индекса ещё нет), и --cacheinfo, т.к. добавляемого файла нет в рабочем каталоге, но он есть в базе данных. Также необходимо передать режим доступа, хеш и имя файла:

$ git update-index --add --cacheinfo 100644 \
  83baae61804e65cc73a7201a7252750c76066a30 test.txt

В данном случае режим доступа — 100644, что означает обычный файл. Другие возможные варианты: 100755 — исполняемый файл, 120000 — символическая ссылка. Режимы доступа в Git’е сделаны по аналогии с режимами доступа в UNIX, но они гораздо менее гибки: данные три режима — единственные доступные для файлов (блобов) в Git’е (хотя существуют и другие режимы, используемые для каталогов и подмодулей).

Теперь можно воспользоваться командой write-tree для сохранения индекса в объект-дерево. Здесь опция -w не требуется — вызов write-tree автоматически создаст объект-дерево по состоянию индекса, если такого дерева ещё не существует:

$ git write-tree
d8329fc1cc938780ffdd9f94e0d364e0ea74f579

$ git cat-file -p d8329fc1cc938780ffdd9f94e0d364e0ea74f579
100644 blob 83baae61804e65cc73a7201a7252750c76066a30      test.txt

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

$ git cat-file -t d8329fc1cc938780ffdd9f94e0d364e0ea74f579
tree

Создадим новое дерево со второй версией файла test.txt и ещё одним файлом:

$ echo 'new file' > new.txt
$ git update-index test.txt 
$ git update-index --add new.txt 

Теперь в индексе содержится новая версия файла test.txt и новый файл new.txt. Запишем это дерево (сохранив состояние индекса в объект-дерево) и посмотрим, что из этого получилось:

$ git write-tree
0155eb4229851634a0f03eb265b69f5a2d56f341

$ git cat-file -p 0155eb4229851634a0f03eb265b69f5a2d56f341
100644 blob fa49b077972391ad58037050f2a75f74e3671e92      new.txt
100644 blob 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a      test.txt

Заметьте, что в данном дереве находятся записи для обоих файлов, а также, что хеш файла test.txt это хеш “второй версии” этого файла (1f7a7a). Для интереса, добавим первое дерево как подкаталог для текущего. Зачитать дерево в индекс можно командой read-tree. В нашем случае, чтобы прочитать уже существующее дерево в индекс и сделать его поддеревом, необходимо использовать опцию --prefix:

$ git read-tree --prefix=bak d8329fc1cc938780ffdd9f94e0d364e0ea74f579
$ git write-tree
3c4e9cd789d88d8d89c1073707c3585e41b0e614

$ git cat-file -p 3c4e9cd789d88d8d89c1073707c3585e41b0e614
040000 tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579      bak
100644 blob fa49b077972391ad58037050f2a75f74e3671e92      new.txt
100644 blob 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a      test.txt

Если бы вы создали рабочий каталог, соответствующий только что созданному дереву, вы бы получили два файла в корне и подкаталог bak со старой версией файла test.txt. Данные, хранящиеся в Git’е для такой структуры, могут быть представлены так, как показано на рисунке 9-2.

Insert 18333fig0902.png Рисунок 9-2. Структура данных Git’а для текущего дерева.

Объекты-коммиты

У нас есть три дерева, соответствующих разным состояниям проекта, но предыдущая проблема с необходимостью запоминать все три значения SHA-1, чтобы иметь возможность восстановить какое-либо из этих состояний, ещё не решена. К тому же у нас нет никакой информации о том, кто, когда и почему сохранил их. Такие данные — основная информация, которая хранится в объекте-коммите.

Для создания объекта-коммита необходимо вызвать commit-tree и задать SHA-1 нужного дерева и, если необходимо, родительские объекты-коммиты. Для начала создадим коммит для самого первого дерева:

$ echo 'first commit' | git commit-tree d8329f
fdf4fc3344e67ab068f836878b6c4951e3b15f3d

Просмотреть вновь созданный объект-коммит можно командой cat-file:

$ git cat-file -p fdf4fc3
tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579
author Scott Chacon <schacon@gmail.com> 1243040974 -0700
committer Scott Chacon <schacon@gmail.com> 1243040974 -0700

first commit

Формат объекта-коммита прост: в нём указано дерево верхнего уровня, соответствующее состоянию проекта на некоторый момент; имена автора и коммитера берутся из полей конфигурации user.name и user.email; также добавляется текущая временная метка, пустая строка и затем сообщение коммита.

Далее, создадим ещё два объекта-коммита, каждый из которых будет ссылаться на предыдущий коммит:

$ echo 'second commit' | git commit-tree 0155eb -p fdf4fc3
cac0cab538b970a37ea1e769cbbde608743bc96d

$ echo 'third commit'  | git commit-tree 3c4e9c -p cac0cab
1a410efbd13591db07496601ebc7a059dd55cfe9

Каждый из трёх объектов-коммитов указывает на одно из состояний проекта. Может показаться странным, но теперь у нас есть полноценная Git-история, которую можно посмотреть командой git log, указав хеш последнего коммита:

$ git log --stat 1a410e
commit 1a410efbd13591db07496601ebc7a059dd55cfe9
Author: Scott Chacon <schacon@gmail.com>
Date:   Fri May 22 18:15:24 2009 -0700

    third commit

 bak/test.txt |    1 +
 1 files changed, 1 insertions(+), 0 deletions(-)

commit cac0cab538b970a37ea1e769cbbde608743bc96d
Author: Scott Chacon <schacon@gmail.com>
Date:   Fri May 22 18:14:29 2009 -0700

    second commit

 new.txt  |    1 +
 test.txt |    2 +-
 2 files changed, 2 insertions(+), 1 deletions(-)

commit fdf4fc3344e67ab068f836878b6c4951e3b15f3d
Author: Scott Chacon <schacon@gmail.com>
Date:   Fri May 22 18:09:34 2009 -0700

    first commit

 test.txt |    1 +
 1 files changed, 1 insertions(+), 0 deletions(-)

Поразительно. Мы только что выполнили низкоуровневые операции для построения истории без использования высокоуровневых интерфейсов. По существу, именно это делает Git, когда выполняются команды git add и git commit — сохраняет блобы для изменённых файлов, обновляет индекс, записывает объекты-деревья и коммит-объекты, ссылающиеся на объекты-деревья верхнего уровня и предшествующие коммиты. Эти три основных вида объектов Git’а: блоб, дерево и коммит — первоначально сохраняются как отдельные файлы в каталоге .git/objects. Вот все объекты, которые сейчас лежат в каталоге с примером (в комментариях написано чему объекты соответствует):

$ find .git/objects -type f
.git/objects/01/55eb4229851634a0f03eb265b69f5a2d56f341 # tree 2
.git/objects/1a/410efbd13591db07496601ebc7a059dd55cfe9 # commit 3
.git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a # test.txt v2
.git/objects/3c/4e9cd789d88d8d89c1073707c3585e41b0e614 # tree 3
.git/objects/83/baae61804e65cc73a7201a7252750c76066a30 # test.txt v1
.git/objects/ca/c0cab538b970a37ea1e769cbbde608743bc96d # commit 2
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4 # 'test content'
.git/objects/d8/329fc1cc938780ffdd9f94e0d364e0ea74f579 # tree 1
.git/objects/fa/49b077972391ad58037050f2a75f74e3671e92 # new.txt
.git/objects/fd/f4fc3344e67ab068f836878b6c4951e3b15f3d # commit 1

Если пройти по всем внутренним ссылкам, получится граф объектов такой, как на рисунке 9-3.

Insert 18333fig0903.png Рисунок 9-3. Все объекты в каталоге Git’а.

Хранение объектов

Ранее я упоминал, что заголовок сохраняется вместе с содержимым. Давайте посмотрим, как сохраняются объекты Git’а на диске. Мы рассмотрим сохранение блоб-объекта, в данном случае это будет строка “есть проблемы, шеф?”. Пример будет выполнен на языке Ruby. Для запуска интерактивного интерпретатора воспользуйтесь командой irb:

$ irb
>> content = "есть проблемы, шеф?"
=> "есть проблемы, шеф?"

Git создаёт заголовок, начинающийся с типа объекта, в данном случае это блоб. Далее добавляется пробел, размер содержимого и в конце нулевой байт:

>> header = "blob #{content.length}\0"
=> "blob 34\000"

Git дописывает содержимое после заголовка и вычисляет SHA-1 сумму для полученного результата. В Ruby значение SHA-1 для строки можно получить, подключив соответствующую библиотеку командой require и затем воспользовавшись вызовом Digest::SHA1.hexdigest():

>> store = header + content
=> "blob 34\000\320\225\321\201\321\202\321\214 \320\277\321\200\320\276\320\261\320\273\320\265\320\274\321\213, \321\210\320\265\321\204?"
>> require 'digest/sha1'
=> true
>> sha1 = Digest::SHA1.hexdigest(store)
=> "d8a734f44240bdf766c8df342664fde23d421d64"

Git сжимает новые данные при помощи zlib. В Ruby это можно сделать с помощью одноимённой библиотеки. Сперва необходимо подключить её, а после вызвать Zlib::Deflate.deflate() с данными в качестве параметра:

>> require 'zlib'
=> true
>> zlib_content = Zlib::Deflate.deflate(store)
=> "x\234\001*\000\325\377blob 34\000\320\225\321\201\321\202\321\214 \320\277\321\200\320\276\320\261\320\273\320\265\320\274\321\213, \321\210\320\265\321\204?\3453\030S"

После этого запишем сжатую zlib’ом строку в объект на диск. Определим путь к файлу, который будет записан (первые два символа хеша используются в качестве названия подкаталога, оставшиеся 38 — в качестве имени файла в этом каталоге). В Ruby для этой задачи можно использовать функцию FileUtils.mkdir_p() для создания подкаталога, если он не существует. Далее, откроем файл вызовом File.open() и запишем наши сжатые данные вызовом write() для полученного файлового дескриптора:

>> path = '.git/objects/' + sha1[0,2] + '/' + sha1[2,38]
=> ".git/objects/d8/a734f44240bdf766c8df342664fde23d421d64"
>> require 'fileutils'
=> true
>> FileUtils.mkdir_p(File.dirname(path))
=> ".git/objects/bd"
>> File.open(path, 'w') { |f| f.write zlib_content }
=> 32

Вот и всё, мы создали корректный объект-блоб для Git’а. Все другие объекты создаются аналогично, меняется только запись о типе в заголовке (blob, commit, tree). Стоит добавить, что хотя в блобе может храниться почти любое содержимое, содержимое объектов-деревьев и объектов-коммитов записывается в очень строгом формате.

Ссылки в Git

Для просмотра всей истории можно выполнить команду вроде git log 1a410e, но, опять же, требуется помнить, что именно коммит 1a410e является последним, чтобы иметь возможность найти все наши объекты. Нам нужен файл-указатель с простым именем, который бы содержал это значение хеша SHA-1, чтобы можно было пользоваться этим файлом вместо хеша.

В Git’е такие файлы, содержащие SHA-1, называются ссылками (“refs”) и располагаются в каталоге .git/refs. В нашем проекте этот каталог пока пуст, но в нём уже существует некоторая структура каталогов:

$ find .git/refs
.git/refs
.git/refs/heads
.git/refs/tags
$ find .git/refs -type f
$

Чтобы создать новую ссылку, которая поможет вам вспомнить, какой коммит последний, по сути, необходимо сделать всего лишь следующее:

$ echo "1a410efbd13591db07496601ebc7a059dd55cfe9" > .git/refs/heads/master

Теперь в командах Git’а вместо хеша можно использовать ссылку, только что созданную в каталоге heads:

$ git log --pretty=oneline  master
1a410efbd13591db07496601ebc7a059dd55cfe9 third commit
cac0cab538b970a37ea1e769cbbde608743bc96d second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d first commit

Тем не менее, редактировать данные файлы напрямую не рекомендуется. Git предоставляет безопасную команду update-ref для изменения ссылок:

$ git update-ref refs/heads/master 1a410efbd13591db07496601ebc7a059dd55cfe9

Вот что такое, по сути, ветка в Git’е — простой указатель или ссылка на последнюю версию в работе. Для создания ветки, соответствующей состоянию второго коммита, можно выполнить следующее:

$ git update-ref refs/heads/test cac0ca

Данная ветка будет содержать только коммиты, предшествующие выбранному:

$ git log --pretty=oneline test
cac0cab538b970a37ea1e769cbbde608743bc96d second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d first commit

Теперь наша база данных Git’а схематично выглядит так, как показано на рисунке 9.4.

Insert 18333fig0904.png Рисунок 9-4. Объекты в каталоге .git, а также указатели на вершины веток.

Когда выполняется команда git branch (имя ветки), Git, по сути, выполняет update-ref для добавления хеша последнего коммита текущей ветки под указанным именем в виде новой ссылки.

Вопрос в том, как же Git получает хеш последнего коммита при выполнении git branch (имя ветки)? Ответ содержится в файле HEAD. Данный файл является символической ссылкой на текущую ветку. Символическая ссылка отличается от обычной тем, что она содержит не сам хеш SHA-1, а указатель на другую ссылку. Если вы заглянете в этот файл, то увидите что-то такое:

$ cat .git/HEAD 
ref: refs/heads/master

Если выполнить git checkout test, то содержимое файла изменится:

$ cat .git/HEAD 
ref: refs/heads/test

При выполнении git commit Git создаёт объект-коммит, указывая его родителем тот объект, SHA-1 которого содержится в файле, на который ссылается HEAD.

Данный файл, конечно, можно редактировать вручную, но безопаснее использовать команду symbolic-ref. Получить значение HEAD данной командой можно так:

$ git symbolic-ref HEAD
refs/heads/master

Изменить значение HEAD можно так:

$ git symbolic-ref HEAD refs/heads/test
$ cat .git/HEAD 
ref: refs/heads/test

Символическую ссылку на файл вне refs поставить нельзя:

$ git symbolic-ref HEAD test
fatal: Refusing to point HEAD outside of refs/

Метки

Мы рассмотрели три основных типа объектов Git’а, но есть и четвёртый. Объект-метка очень похож на объект-коммит: он содержит имя поставившего метку, дату, сообщение и указатель. Разница же в том, что метка указывает на коммит, а не на дерево. Она похожа на ветку, которая никогда не перемещается — она всегда указывает на один и тот же коммит, она просто даёт ему понятное имя.

Как было сказано, метки бывают двух типов: аннотированные и легковесные. Легковесную метку можно сделать следующей командой:

$ git update-ref refs/tags/v1.0 cac0cab538b970a37ea1e769cbbde608743bc96d

Вот и всё! Легковесная метка — это ветка, которая никогда не перемещается. Аннотированная метка имеет более сложную структуру. При создании аннотированной метки Git создаёт специальный объект, на который будет указывать ссылка, а не просто указатель на коммит. Мы можем увидеть это, создав аннотированную метку (-a задаёт аннотированные метки):

$ git tag -a v1.1 1a410efbd13591db07496601ebc7a059dd55cfe9 -m 'test tag'

Вот значение SHA-1 созданного объекта:

$ cat .git/refs/tags/v1.1 
9585191f37f7b0fb9444f35a9bf50de191beadc2

Теперь выполним cat-file для этого хеша:

$ git cat-file -p 9585191f37f7b0fb9444f35a9bf50de191beadc2
object 1a410efbd13591db07496601ebc7a059dd55cfe9
type commit
tag v1.1
tagger Scott Chacon <schacon@gmail.com> Sat May 23 16:48:58 2009 -0700

test tag

Заметьте, в поле object записан SHA-1 коммита, для которого мы делали метку. Также стоит отметить, что это поле не обязательно указывает на коммит, но на любой объект в Git’е. Например, в исходный код Git’а мейнтейнер добавил свой открытый GPG-ключ в качестве блоба и поставил для него метку. Увидеть этот ключ можно, выполнив команду

$ git cat-file blob junio-gpg-pub

в репозитории с исходным кодом Git’а. В репозитории ядра Linux также есть метка, указывающая не на коммит — первая метка указывает на дерево первичного импорта.

Ссылки на удалённые ветки

Третий тип ссылок, который мы рассмотрим — ссылка на удалённую ветку. Если вы добавили удалённый репозиторий и отправили (push) на него изменения, Git сохранит последнее отправленное значение SHA-1 в каталоге refs/remotes для всех отправленных веток. Например, можно добавить удалённый репозиторий origin и отправить туда ветку master:

$ git remote add origin git@github.com:schacon/simplegit-progit.git
$ git push origin master
Counting objects: 11, done.
Compressing objects: 100% (5/5), done.
Writing objects: 100% (7/7), 716 bytes, done.
Total 7 (delta 2), reused 4 (delta 1)
To git@github.com:schacon/simplegit-progit.git
   a11bef0..ca82a6d  master -> master

Позже вы сможете посмотреть, где находилась ветка master с сервера origin во время последнего соединения с сервером, заглянув в файл refs/remotes/origin/master:

$ cat .git/refs/remotes/origin/master 
ca82a6dff817ec66f44342007202690a93763949

Ссылки на удалённые ветки отличаются от обычных веток (ссылки в refs/heads) тем, что на них нельзя переключиться с помощью git checkout. Git работает с ними как с закладками, указывающими на последнее состояние соответствующих веток на ваших серверах.

Pack-файлы

Вернёмся к базе объектов в нашем тестовом репозитории. К этому моменту их должно быть 11 штук: 4 блоба, 3 дерева, 3 коммита и одна метка:

$ find .git/objects -type f
.git/objects/01/55eb4229851634a0f03eb265b69f5a2d56f341 # tree 2
.git/objects/1a/410efbd13591db07496601ebc7a059dd55cfe9 # commit 3
.git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a # test.txt v2
.git/objects/3c/4e9cd789d88d8d89c1073707c3585e41b0e614 # tree 3
.git/objects/83/baae61804e65cc73a7201a7252750c76066a30 # test.txt v1
.git/objects/95/85191f37f7b0fb9444f35a9bf50de191beadc2 # tag
.git/objects/ca/c0cab538b970a37ea1e769cbbde608743bc96d # commit 2
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4 # 'test content'
.git/objects/d8/329fc1cc938780ffdd9f94e0d364e0ea74f579 # tree 1
.git/objects/fa/49b077972391ad58037050f2a75f74e3671e92 # new.txt
.git/objects/fd/f4fc3344e67ab068f836878b6c4951e3b15f3d # commit 1

Git сжал содержимое этих файлов при помощи zlib, к тому же мы не записывали много данных, поэтому все эти файлы вместе занимают всего 925 байт. Для того чтобы продемонстрировать одну интересную возможность Git’а, добавим файл побольше. Добавим файл repo.rb из библиотеки Grit, с которой мы работали ранее, он занимает примерно 12 Кбайт:

$ curl https://raw.github.com/mojombo/grit/master/lib/grit/repo.rb > repo.rb
$ git add repo.rb 
$ git commit -m 'added repo.rb'
[master 484a592] added repo.rb
 3 files changed, 459 insertions(+), 2 deletions(-)
 delete mode 100644 bak/test.txt
 create mode 100644 repo.rb
 rewrite test.txt (100%)

Если мы посмотрим на полученное дерево, мы увидим значение SHA-1, которое получил блоб для файла repo.rb:

$ git cat-file -p master^{tree}
100644 blob fa49b077972391ad58037050f2a75f74e3671e92      new.txt
100644 blob 9bc1dc421dcd51b4ac296e3e5b6e2a99cf44391e      repo.rb
100644 blob e3f094f522629ae358806b17daf78246c27c007b      test.txt

Посмотрим, сколько этот объект занимает места на диске:

$ du -b .git/objects/9b/c1dc421dcd51b4ac296e3e5b6e2a99cf44391e
4102	.git/objects/9b/c1dc421dcd51b4ac296e3e5b6e2a99cf44391e

Теперь изменим немного данный файл и посмотрим на результат:

$ echo '# testing' >> repo.rb 
$ git commit -am 'modified repo a bit'
[master ab1afef] modified repo a bit
 1 files changed, 1 insertions(+), 0 deletions(-)

Взглянув на дерево, полученное в результате коммита, мы увидим любопытную вещь:

$ git cat-file -p master^{tree}
100644 blob fa49b077972391ad58037050f2a75f74e3671e92      new.txt
100644 blob 05408d195263d853f09dca71d55116663690c27c      repo.rb
100644 blob e3f094f522629ae358806b17daf78246c27c007b      test.txt

Теперь файлу repo.rb соответствует другой объект-блоб. Это означает, что даже одна единственная строка, добавленная в конец 400-строчного файла, требует создания абсолютно нового объекта:

$ du -b .git/objects/05/408d195263d853f09dca71d55116663690c27c
4109	.git/objects/05/408d195263d853f09dca71d55116663690c27c

Итак, мы имеем два почти одинаковых объекта занимающих по 4 Кбайта на диске. Было бы неплохо, если бы Git сохранял только один объект целиком, а другой как разницу между ним и первым объектом.

Оказывается, что Git так и делает. Первоначальный формат для сохранения объектов в Git’е называется рыхлым форматом (loose format) объектов. Однако, время от времени Git упаковывает несколько таких объектов в один pack-файл (pack в пер. с англ. — упаковывать, уплотнять) для сохранения места на диске и повышения эффективности. Это происходит, когда “рыхлых” объектов становится слишком много, а также при вызове git gc вручную, и при отправке изменений на удалённый сервер. Чтобы посмотреть, как происходит упаковка, можно выполнить команду git gc:

$ git gc
Counting objects: 17, done.
Delta compression using 2 threads.
Compressing objects: 100% (13/13), done.
Writing objects: 100% (17/17), done.
Total 17 (delta 1), reused 10 (delta 0)

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

$ find .git/objects -type f
.git/objects/71/08f7ecb345ee9d0084193f147cdad4d2998293
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4
.git/objects/info/packs
.git/objects/pack/pack-7a16e4488ae40c7d2bc56ea2bd43e25212a66c45.idx
.git/objects/pack/pack-7a16e4488ae40c7d2bc56ea2bd43e25212a66c45.pack

Оставшиеся объекты — блобы, на которые не указывает ни один коммит. В нашем случае это созданные ранее объекты: содержащий строку “есть проблемы, шеф?”, и блоб содержащий “test content”. В силу того, что ни в одном коммите данные файлы не присутствуют, они считаются “висячими” и не упаковываются.

Остальные файлы — это pack-файл и его индекс. Pack-файл — это файл, который теперь содержит все объекты, которые были удалены. А индекс — это файл, в котором записаны их смещения в pack-файле, что даёт возможность быстро найти нужный объект. Упаковка данных положительно повлияла на общий размер файлов, если до вызова gc они занимали примерно 8 Кбайт, то pack-файл занимает всего 4 Кбайт. Упаковкой объектов мы смогли сократить место, занятое на диске, в два раза.

Как Git это делает? При упаковке Git ищет файлы, которые похожи по имени и размеру, и сохраняет только разницу между двумя версиями файла. Можно рассмотреть pack-файл подробнее и понять, какие действия были выполнены для сжатия. Для просмотра содержимого упакованного файла существует служебная команда git verify-pack:

$ git verify-pack -v \
  .git/objects/pack/pack-7a16e4488ae40c7d2bc56ea2bd43e25212a66c45.idx
0155eb4229851634a0f03eb265b69f5a2d56f341 tree   71 76 5400
05408d195263d853f09dca71d55116663690c27c blob   12908 3478 874
09f01cea547666f58d6a8d809583841a7c6f0130 tree   106 107 5086
1a410efbd13591db07496601ebc7a059dd55cfe9 commit 225 151 322
1f7a7a472abf3dd9643fd615f6da379c4acb3e3a blob   10 19 5381
3c4e9cd789d88d8d89c1073707c3585e41b0e614 tree   101 105 5211
484a59275031909e19aadb7c92262719cfcdf19a commit 226 153 169
83baae61804e65cc73a7201a7252750c76066a30 blob   10 19 5362
9585191f37f7b0fb9444f35a9bf50de191beadc2 tag    136 127 5476
9bc1dc421dcd51b4ac296e3e5b6e2a99cf44391e blob   7 18 5193 1
05408d195263d853f09dca71d55116663690c27c
ab1afef80fac8e34258ff41fc1b867c702daa24b commit 232 157 12
cac0cab538b970a37ea1e769cbbde608743bc96d commit 226 154 473
d8329fc1cc938780ffdd9f94e0d364e0ea74f579 tree   36 46 5316
e3f094f522629ae358806b17daf78246c27c007b blob   1486 734 4352
f8f51d7d8a1760462eca26eebafde32087499533 tree   106 107 749
fa49b077972391ad58037050f2a75f74e3671e92 blob   9 18 856
fdf4fc3344e67ab068f836878b6c4951e3b15f3d commit 177 122 627
chain length = 1: 1 object
pack-7a16e4488ae40c7d2bc56ea2bd43e25212a66c45.pack: ok

Здесь блоб 9bc1d, который, как мы помним, был первой версией файла repo.rb, ссылается на блоб 05408, который был второй его версией. Третья колонка в выводе — это размер содержимого объекта. Как видите, содержимое 05408 занимает 12 Кбайт, при этом содержимое 9bc1d занимает всего лишь 7 байт. Что интересно, вторая версия сохраняется “как есть”, а исходная — в виде дельты. Это из-за того, что необходимость получения доступа к последней версии файла является более вероятной.

Также здорово, что переупаковку можно выполнять в любое время. Время от времени Git будет выполнять её автоматически, чтобы сэкономить место на диске. Если вдруг этого недостаточно, всегда можно выполнить git gc вручную.

Спецификации ссылок

Во всей книге использовались простые связи между ветками в удалённых репозиториях и локальными ветками, но они могут быть и более сложными. Предположим, мы добавили следующий удалённый репозиторий:

$ git remote add origin git@github.com:schacon/simplegit-progit.git

Данный вызов добавляет секцию в файл .git/config, в которой заданы имя удалённого репозитория (origin), его URL и спецификация ссылок для извлечения данных:

[remote "origin"]
       url = git@github.com:schacon/simplegit-progit.git
       fetch = +refs/heads/*:refs/remotes/origin/*

Формат спецификации следующий: опциональный +, далее пара <src>:<dst>, где <src> — шаблон ссылок в удалённом репозитории, а <dst> — соответствующий шаблон локальных ссылок. Символ + сообщает Git’у, что обновление необходимо выполнять даже в том случае, если оно не является перемоткой.

В случае настроек по умолчанию, которые записываются во время выполнения git remote add, Git выбирает все ссылки из refs/heads/ на стороне сервера, и записывает их в локальный каталог refs/remotes/origin/. Таким образом, если на сервере есть ветка master, журнал данной ветки можно получить, вызвав:

$ git log origin/master
$ git log remotes/origin/master
$ git log refs/remotes/origin/master

Все эти команды эквивалентны, так как Git развернёт каждую запись до refs/remotes/origin/master.

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

fetch = +refs/heads/master:refs/remotes/origin/master

Данный refspec будет использоваться по умолчанию при вызове git fetch для данного удалённого репозитория. Если же вам нужно изменить спецификацию всего раз, можно задать refspec в командной строке. Например, чтобы получить данные из ветки master из удалённого репозитория в локальную origin/mymaster, можно выполнить

$ git fetch origin master:refs/remotes/origin/mymaster

Конечно, можно задать несколько спецификаций. Получить данные нескольких веток из командной строки можно так:

$ git fetch origin master:refs/remotes/origin/mymaster \
   topic:refs/remotes/origin/topic
From git@github.com:schacon/simplegit
 ! [rejected]        master     -> origin/mymaster  (non fast forward)
 * [new branch]      topic      -> origin/topic

В данном случае слияние ветки master выполнить не удалось, поскольку слияние не было просто перемоткой. Такое поведение можно изменить, добавив перед спецификацией знак +.

В конфигурационном файле также можно задавать несколько спецификаций для получения обновлений. Чтобы каждый раз получать обновления веток master и experiment, добавьте две такие строки:

[remote "origin"]
       url = git@github.com:schacon/simplegit-progit.git
       fetch = +refs/heads/master:refs/remotes/origin/master
       fetch = +refs/heads/experiment:refs/remotes/origin/experiment

Задавать частичные регулярные выражения в спецификации нельзя, следующая запись неверна:

fetch = +refs/heads/qa*:refs/remotes/origin/qa*

Тем не менее, можно использовать пространства имён для получения похожего результата. Если имеется команда QA (сокр. от quality assurance — контроль качества), которая использует свои несколько веток, и вы хотите получать только ветку master и все ветки команды QA, а остальные — нет, то можно добавить в конфигурацию следующее:

[remote "origin"]
       url = git@github.com:schacon/simplegit-progit.git
       fetch = +refs/heads/master:refs/remotes/origin/master
       fetch = +refs/heads/qa/*:refs/remotes/origin/qa/*

Если ваш рабочий процесс является сложным, и разные команды: разработчики, тестеры, внедренцы — коммитят в разные ветки одного и того же проекта, то так вы с лёгкостью можете разделить их по разным пространствам имён.

Спецификации ссылок для команды push

Это хорошо, что мы научились получать данные по ссылкам в отдельных пространствах имён, но нам же ещё надо сделать так, чтобы команда QA сначала смогла отправить свои ветки в пространство имён qa/. Мы решим эту задачу, используя спецификации ссылок для команды push.

Если разработчик из команды QA хочет отправить изменения из локальной ветки master в qa/master на удалённом сервере, он может выполнить команду

$ git push origin master:refs/heads/qa/master

Если хочется, чтобы Git автоматически делал так при вызове git push origin, можно добавить в конфигурационный файл значение для push:

[remote "origin"]
       url = git@github.com:schacon/simplegit-progit.git
       fetch = +refs/heads/*:refs/remotes/origin/*
       push = refs/heads/master:refs/heads/qa/master

Опять же, это приведёт к тому, что при вызове git push origin локальная ветка master будет по умолчанию отправляться в удалённую ветку qa/master.

Удаление ссылок

Кроме всего прочего, спецификации ссылок можно использовать следующим образом для удаления ссылок на удалённом сервере:

$ git push origin :topic

Так как спецификация ссылки задаётся в виде <src>:<dst>, опускание <src> означает, что указанную ветку на удалённом сервере надо сделать пустой, что приводит к её удалению.

Протоколы передачи

Git может передавать данные между репозиториями одним из двух основных способов: через HTTP или через “умные” протоколы для транспортов file://, ssh:// и git://. В данном разделе мы кратко рассмотрим, как эти два протокола работают.

Тупой протокол

Git-транспорт, работающий по HTTP, часто называют “тупым” протоколом, потому что для его работы во время передачи данных не требуется исполнения никакого Git-специфичного кода на стороне сервера. Процесс извлечения данных представляет собой последовательность GET-запросов, клиент обращается к стандартной структуре каталогов Git’а. Давайте рассмотрим процесс получения данных по HTTP на примере библиотеки simplegit:

$ git clone http://github.com/schacon/simplegit-progit.git

Первое действие, выполняемое данной командой — загрузка файла info/refs. Данный файл записывается командой update-server-info, поэтому для использования HTTP-транспорта необходимо запускать эту команду в перехватчике post-receive:

=> GET info/refs
ca82a6dff817ec66f44342007202690a93763949     refs/heads/master

Теперь у нас имеется список удалённых веток и их хеши. Далее, нам надо посмотреть, куда ссылается HEAD, чтобы знать на какую версию переключиться после завершения работы команды.

=> GET HEAD
ref: refs/heads/master

Нам надо переключиться на ветку master после завершения процесса. На данном этапе можно начать обход дерева. Начальной точкой является объект-коммит ca82a6, о чём мы узнали из файла info/refs, и мы начинаем с его загрузки:

=> GET objects/ca/82a6dff817ec66f44342007202690a93763949
(179 bytes of binary data)

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

$ git cat-file -p ca82a6dff817ec66f44342007202690a93763949
tree cfda3bf379e4f8dba8717dee55aab78aef7f4daf
parent 085bb3bcb608e1e8451d4b2432f8ecbe6306e7e7
author Scott Chacon <schacon@gmail.com> 1205815931 -0700
committer Scott Chacon <schacon@gmail.com> 1240030591 -0700

changed the version number

Далее, необходимо загрузить ещё два объекта: cfda3b — объект-дерево, который обозначен как содержимое только что загруженного коммита, и 085bb3 — родительский коммит:

=> GET objects/08/5bb3bcb608e1e8451d4b2432f8ecbe6306e7e7
(179 bytes of data)

Так, мы получили следующий объект-коммит. Прихватим и наш объект-дерево:

=> GET objects/cf/da3bf379e4f8dba8717dee55aab78aef7f4daf
(404 - Not Found)

Ой! Похоже, этого объекта-дерева нет на сервере в рыхлом формате, поэтому мы получили ответ 404. У этого могут быть две причины: или объект в другом репозитории, или в упакованном файле текущего репозитория. Сперва Git проверяет список альтернативных репозиториев:

=> GET objects/info/http-alternates
(empty file)

Если бы этот запрос вернул нам список альтернативных URL, Git обратился бы по ним в поиске “рыхлых” и pack-файлов — это такой механизм, позволяющий не дублировать данные проектам, являющимися форками друг для друга. Так как в данном случае альтернативных адресов нет, объект должен быть в pack-файле. Для того чтобы узнать, какие упакованные файлы есть на сервере, необходимо загрузить файл со списком pack-файлов: objects/info/packs (который также генерируется update-server-info):

=> GET objects/info/packs
P pack-816a9b2334da9953e530f27bcac22082a9f5b835.pack

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

=> GET objects/pack/pack-816a9b2334da9953e530f27bcac22082a9f5b835.idx
(4k of binary data)

Теперь, когда мы получили индекс упакованного файла, можно проверить, тут ли наш объект. Это возможно благодаря тому, что в индексе хранятся SHA-1 объектов содержащихся в pack-файле, а также их смещения. Необходимый объект там присутствует, так что продолжим и получим весь pack-файл:

=> GET objects/pack/pack-816a9b2334da9953e530f27bcac22082a9f5b835.pack
(13k of binary data)

Итак, мы получили наш объект-дерево, можно продолжить обход списка коммитов. Все они лежат внутри упакованного файла, который мы только что скачали, так что снова обращаться к серверу не надо. Git извлекает рабочую копию ветки master, на которую ссылается HEAD.

Полный вывод этого процесса выглядит так:

$ git clone http://github.com/schacon/simplegit-progit.git
Initialized empty Git repository in /private/tmp/simplegit-progit/.git/
got ca82a6dff817ec66f44342007202690a93763949
walk ca82a6dff817ec66f44342007202690a93763949
got 085bb3bcb608e1e8451d4b2432f8ecbe6306e7e7
Getting alternates list for http://github.com/schacon/simplegit-progit.git
Getting pack list for http://github.com/schacon/simplegit-progit.git
Getting index for pack 816a9b2334da9953e530f27bcac22082a9f5b835
Getting pack 816a9b2334da9953e530f27bcac22082a9f5b835
 which contains cfda3bf379e4f8dba8717dee55aab78aef7f4daf
walk 085bb3bcb608e1e8451d4b2432f8ecbe6306e7e7
walk a11bef06a3f659402fe7563abf99ad00de2209e6

Умный протокол

Методика работы HTTP проста, но неэффективна, поэтому чаще используются “умные” протоколы. Эти протоколы обслуживаются процессом на стороне сервера, который учитывает особенности работы Git’а — он считывает локальные данные, выясняет, что есть и чего не хватает на клиенте, и генерирует для него соответствующие данные. Существует два набора процессов передачи данных: процессы для загрузки данных и процессы для скачивания.

Загрузка данных

Для загрузки данных на удалённый сервер используются процессы send-pack и receive-pack. Процесс send-pack запускается на стороне клиента и подключается к receive-pack на стороне сервера.

Например, выполняется команда git push origin master и origin определён как URL использующий протокол SSH. Git запускает процесс send-pack, который устанавливает соединение с сервером по протоколу SSH. Он пытается запустить команду на удалённом сервере через вызов команды ssh, который выглядит следующим образом:

$ ssh -x git@github.com "git-receive-pack 'schacon/simplegit-progit.git'"
005bca82a6dff817ec66f4437202690a93763949 refs/heads/master report-status delete-refs
003e085bb3bcb608e1e84b2432f8ecbe6306e7e7 refs/heads/topic
0000

Команда git-receive-pack тут же посылает в ответ по одной строке на каждую из имеющихся в наличии ссылок — в данном случае только ветку master и её SHA. Первая строка также содержит список возможностей сервера (здесь это report-status и delete-refs).

Каждая строка начинается с 4-байтового шестнадцатеричного значения, содержащего длину оставшейся строки. Первая строка начинается с 005b, это 91 в 16-ричном виде, значит в этой строке ещё 91 байт. Следующая строка начинается с 003e, что означает 62, то есть надо прочитать 62 байта. Далее следует строка 0000, которая означает, что сервер закончил листинг своих ссылок.

Теперь, когда процесс send-pack выяснил состояние сервера, он определяет коммиты, которые есть локально, но которых нет на сервере. Для каждой ссылки, которая будет обновлена текущей командой push, процесс send-pack передаёт процессу receive-pack эти данные. Например, если мы обновляем ветку master, и добавляем ветку experiment, ответ send-pack будет выглядеть следующим образом:

0085ca82a6dff817ec66f44342007202690a93763949  15027957951b64cf874c3557a0f3547bd83b3ff6 refs/heads/master report-status
00670000000000000000000000000000000000000000 cdfdb42577e2506715f8cfeacdbabc092bf63e8d refs/heads/experiment
0000

Значение SHA-1 из одних нулей означает, что раньше здесь ничего не было — так получилось из-за того, что мы добавили новую ссылку experiment. Если бы мы удаляли ссылку, было бы на оборот: одни нули были бы справа.

Git отправляет строку для каждой ссылки, для которой производится обновление. В строке содержится старый хеш, новый хеш и имя обновляемой ссылки. Первая строка также содержит возможности клиента. Далее, клиент загружает упакованный файл со всеми объектами, которых ещё нет на сервере. В конце, сервер отвечает статусным сообщением сообщающем об успехе (или ошибке):

000Aunpack ok

Скачивание данных

Если выполняется скачивание данных, используются процессы fetch-pack и upload-pack. Клиент запускает процесс fetch-pack, который подключается к процессу upload-pack на удалённой машине для определения, какие данные будут переданы.

Существуют разные способы запуска upload-pack на удалённом репозитории. Можно запустить его по SSH так же, как и receive-pack. Ещё можно вызвать процесс через Git-демон, по умолчанию принимающий соединения на порте 9418. Процесс fetch-pack после подключения отправляет демону данные примерно следующего вида:

003fgit-upload-pack schacon/simplegit-progit.git\0host=myserver.com\0

Начальные 4 байта задают размер последующих данных, далее следует команда, которую следует запустить, завершаемая нулевым байтом, а потом имя сервера и последний нулевой байт. Git-демон проверяет возможность выполнения команды, а также, что репозиторий существует и имеет необходимые права доступа. Если всё хорошо, демон запускает процесс upload-pack и передаёт запрос ему.

Если извлечение данных производится по SSH, fetch-pack выполняет другие действия:

$ ssh -x git@github.com "git-upload-pack 'schacon/simplegit-progit.git'"

В обоих случаях, после того как fetch-pack подключится, upload-pack передаст обратно следующее:

0088ca82a6dff817ec66f44342007202690a93763949 HEAD\0multi_ack thin-pack \
  side-band side-band-64k ofs-delta shallow no-progress include-tag
003fca82a6dff817ec66f44342007202690a93763949 refs/heads/master
003e085bb3bcb608e1e8451d4b2432f8ecbe6306e7e7 refs/heads/topic
0000

Это очень похоже на ответ receive-pack, но только возможности другие. Вдобавок upload-pack отсылает обратно ссылку HEAD, чтобы клиент понимал, на какую ветку переключиться, если выполняется клонирование.

На данном этапе процесс fetch-pack смотрит на объекты, имеющиеся в наличии, и для недостающих объектов отвечает словом “want” и за ним SHA объекта. Для уже имеющихся объектов процесс отправляет их хеши со словом “have”. В конце списка он пишет “done”, и это даёт понять процессу upload-pack, что пора начинать отправлять упакованный файл с необходимыми данными:

0054want ca82a6dff817ec66f44342007202690a93763949 ofs-delta
0032have 085bb3bcb608e1e8451d4b2432f8ecbe6306e7e7
0000
0009done

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

Обслуживание и восстановление данных

Иногда требуется выполнить очистку — сделать репозиторий более компактным, почистить импортированный репозиторий или восстановить потерянную работу. Данный раздел охватывает некоторые из этих сценариев.

Обслуживание

Иногда Git сам выполняет команду, запускающую автоматический сборщик мусора. Чаще всего эта команда ничего не делает. Однако, если неупакованных объектов слишком много или у вас слишком много pack-файлов, Git запускает полноценную команду git gc. Здесь gc это сокращение от “garbage collect”, что означает “сборка мусора”. Эта команда выполняет несколько действий: собирает все объекты в рыхлом формате и упаковывает их в pack-файлы, объединяет несколько упакованных файлов в один большой, удаляет объекты, недостижимые ни из одного коммита и хранящиеся дольше нескольких месяцев.

Вы также можете запустить сборку мусора вручную:

$ git gc --auto

Опять же, как правило, эта команда ничего не делает. Необходимо иметь 7000 несжатых объектов или более 50 упакованных файлов, чтобы запустился настоящий gc. Данные пределы можно изменить с помощью параметров gc.auto и gc.autopacklimit в конфигурационном файле.

Другое действие, выполняемое gc — упаковка ссылок в единый файл. Предположим, репозиторий содержит следующие ветки и теги:

$ find .git/refs -type f
.git/refs/heads/experiment
.git/refs/heads/master
.git/refs/tags/v1.0
.git/refs/tags/v1.1

Если выполнить git gc, данные файлы в каталоге refs перестанут существовать. Git перенесёт их в файл .git/packed-refs в угоду эффективности. Файл будет иметь следующий вид:

$ cat .git/packed-refs 
# pack-refs with: peeled 
cac0cab538b970a37ea1e769cbbde608743bc96d refs/heads/experiment
ab1afef80fac8e34258ff41fc1b867c702daa24b refs/heads/master
cac0cab538b970a37ea1e769cbbde608743bc96d refs/tags/v1.0
9585191f37f7b0fb9444f35a9bf50de191beadc2 refs/tags/v1.1
^1a410efbd13591db07496601ebc7a059dd55cfe9

При обновлении ссылки Git не будет редактировать этот файл, а добавит новый файл в refs/heads. Для получения хеша для нужной ссылки Git сначала проверит наличие ссылки в каталоге refs, а к файлу packed-refs обратится только в случае неудачи. Однако, если в каталоге refs файла нет, скорее всего, он в packed-refs.

Заметьте, последняя строка файла начинается с ^. Это означает, что метка непосредственно над ней является аннотированной и данная строка — это коммит, на который аннотированная метка указывает.

Восстановление данных

В какой-то момент при работе с Git’ом вы нечаянно можете потерять коммит. Как правило, такое случается, когда вы удаляете ветку, в которой находились некоторые наработки, а потом оказывается, что они всё-таки были нужными. Либо вы жёстко сбросили ветку, тем самым отказавшись от коммитов, которые теперь понадобились. Как же в таком случае заполучить свои коммиты обратно?

Рассмотрим пример, в котором жёстко сбросим ветку master в тестовом репозитории на какой-нибудь более ранний коммит и затем восстановим потерянные коммиты. Для начала рассмотрим, в каком состоянии находится репозиторий на данном этапе:

$ git log --pretty=oneline
ab1afef80fac8e34258ff41fc1b867c702daa24b modified repo a bit
484a59275031909e19aadb7c92262719cfcdf19a added repo.rb
1a410efbd13591db07496601ebc7a059dd55cfe9 third commit
cac0cab538b970a37ea1e769cbbde608743bc96d second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d first commit

Теперь сдвинем ветку master на несколько коммитов назад:

$ git reset --hard 1a410efbd13591db07496601ebc7a059dd55cfe9
HEAD is now at 1a410ef third commit
$ git log --pretty=oneline
1a410efbd13591db07496601ebc7a059dd55cfe9 third commit
cac0cab538b970a37ea1e769cbbde608743bc96d second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d first commit

Итак, теперь два последних коммита по-настоящему потеряны — они не достижимы ни из одной ветки. Необходимо найти SHA последнего коммита и создать ветку, указывающую на него. Сложность в том, чтобы найти этот самый SHA последнего коммита, ведь вряд ли вы его запомнили, да?

Зачастую самый быстрый способ — использовать инструмент под названием git reflog. Во время вашей работы Git записывает все изменения HEAD. Каждый раз при переключении веток и коммите, добавляется запись в reflog. Также обновление производится при вызове git update-ref, это, в частности, является причиной необходимости использования этой команды вместо прямой записи значения хеша в ref-файл, как было рассмотрено в разделе “Ссылки в Git”. Итак, изменения HEAD в хронологическом порядке можно увидеть, вызвав git reflog:

$ git reflog
1a410ef HEAD@{0}: 1a410efbd13591db07496601ebc7a059dd55cfe9: updating HEAD
ab1afef HEAD@{1}: ab1afef80fac8e34258ff41fc1b867c702daa24b: updating HEAD

Здесь мы видим два коммита, на которых мы когда-то находились, однако информации не так много. Более интересный вывод можно получить, используя git log -g, что даст стандартный вывод лога для записей из reflog:

$ git log -g
commit 1a410efbd13591db07496601ebc7a059dd55cfe9
Reflog: HEAD@{0} (Scott Chacon <schacon@gmail.com>)
Reflog message: updating HEAD
Author: Scott Chacon <schacon@gmail.com>
Date:   Fri May 22 18:22:37 2009 -0700

    third commit

commit ab1afef80fac8e34258ff41fc1b867c702daa24b
Reflog: HEAD@{1} (Scott Chacon <schacon@gmail.com>)
Reflog message: updating HEAD
Author: Scott Chacon <schacon@gmail.com>
Date:   Fri May 22 18:15:24 2009 -0700

     modified repo a bit

Похоже, что нижний коммит — это тот, который мы потеряли, и он может быть восстановлен созданием ветки, указывающей на него. Например, создадим ветку с именем recover-branch, указывающую на этот коммит (ab1afef):

$ git branch recover-branch ab1afef
$ git log --pretty=oneline recover-branch
ab1afef80fac8e34258ff41fc1b867c702daa24b modified repo a bit
484a59275031909e19aadb7c92262719cfcdf19a added repo.rb
1a410efbd13591db07496601ebc7a059dd55cfe9 third commit
cac0cab538b970a37ea1e769cbbde608743bc96d second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d first commit

Здорово, теперь у нас есть ветка recover-branch, указывающая туда, куда ранее указывала master, и потерянные коммиты вновь доступны. Теперь, положим, потерянная ветка по какой-то причине не попала в reflog, для этого удалим восстановленную ветку и весь reflog. Теперь два первых коммита недоступны ниоткуда:

$ git branch -D recover-branch
$ rm -Rf .git/logs/

Теперь данные из .git/logs/ удалены, а значит, и reflog больше нет, так как все его данные находились там. Как восстановить коммиты теперь? Один способ — использовать утилиту git fsck, проверяющую базу на целостность. Если выполнить её с ключом --full, будут показаны все объекты, недостижимые из других объектов:

$ git fsck --full
dangling blob d670460b4b4aece5915caf5c68d12f560a9fe3e4
dangling commit ab1afef80fac8e34258ff41fc1b867c702daa24b
dangling tree aea790b9a58f6cf6f2804eeac9f0abbe9631e4c9
dangling blob 7108f7ecb345ee9d0084193f147cdad4d2998293

В данном случае потерянный коммит указан после слов “dangling commit” (dangling commit в пер. с англ. — “висячий” коммит). Его можно восстановить аналогичным образом, добавив ветку, указывающую на данный хеш.

Удаление объектов

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

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

Будьте внимательны, данный способ разрушителен по отношению к истории коммитов. Каждый коммит будет переписан, начиная с самого раннего, из которого вы удалите ссылку на большой файл. Если сделать это непосредственно после импорта, когда никто ещё не работал с репозиторием, всё хорошо, иначе придётся сообщать всем участникам разработки о необходимости перемещения их правок на новые коммиты.

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

$ curl http://kernel.org/pub/software/scm/git/git-1.6.3.1.tar.bz2 > git.tbz2
$ git add git.tbz2
$ git commit -am 'added git tarball'
[master 6df7640] added git tarball
 1 files changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 git.tbz2

Упс, кажется, этот огромный архив нам в проекте не нужен. Избавимся от него:

$ git rm git.tbz2 
rm 'git.tbz2'
$ git commit -m 'oops - removed large tarball'
[master da3f30d] oops - removed large tarball
 1 files changed, 0 insertions(+), 0 deletions(-)
 delete mode 100644 git.tbz2

Теперь “соберём мусор” в базе и узнаем её размер:

$ git gc
Counting objects: 21, done.
Delta compression using 2 threads.
Compressing objects: 100% (16/16), done.
Writing objects: 100% (21/21), done.
Total 21 (delta 3), reused 15 (delta 1)

Чтобы быстро узнать, сколько у нас занято места, можно воспользоваться командой count-objects:

$ git count-objects -v
count: 4
size: 16
in-pack: 21
packs: 1
size-pack: 2016
prune-packable: 0
garbage: 0

Запись size-pack — это размер упакованных файлов в килобайтах, то есть всего занято 2 MБ. Перед последним коммитом использовалось около 2 КБ, то есть удаление файла не удалило его из истории. Из-за того, что мы однажды случайно добавили большой файл, при каждом клонировании этого репозитория каждому человеку придётся скачивать все эти 2 МБ, только для того, чтобы получить этот крошечный проект. Попробуем избавиться от этого файла.

Сперва найдём его. В данном случае, мы знаем, что это за файл. Но если бы не знали, как можно было бы определить, какие файлы занимают много места? При вызове git gc все объекты упаковываются в один файл, но, несмотря на это, определить самые крупные файлы можно, запустив служебную команду git verify-pack, и отсортировав её вывод по третьей колонке, в которой записан размер файла. К тому же, так как нас интересуют только самые крупные файлы, оставим только последние несколько строк, направив вывод команде tail:

$ git verify-pack -v .git/objects/pack/pack-3f8c0...bb.idx | sort -k 3 -n | tail -3
e3f094f522629ae358806b17daf78246c27c007b blob   1486 734 4667
05408d195263d853f09dca71d55116663690c27c blob   12908 3478 1189
7a9eb2fba2b1811321254ac360970fc169ba2330 blob   2056716 2056872 5401

Большой объект в самом внизу, его размер — 2 МБ. Для того чтобы узнать, что это за файл, воспользуемся командой rev-list. Если передать ей ключ --objects, то она выдаст хеши всех коммитов, а также хеши объектов и соответствующие им имена файлов. Воспользуемся этим для определения имени выбранного объекта:

$ git rev-list --objects --all | grep 7a9eb2fb
7a9eb2fba2b1811321254ac360970fc169ba2330 git.tbz2

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

$ git log --pretty=oneline --branches -- git.tbz2
da3f30d019005479c99eb4c3406225613985a1db oops - removed large tarball
6df764092f3e7c8f5f94cbe08ee5cf42e92a0289 added git tarball

Необходимо переписать все коммиты, начиная с 6df76 для полного удаления данного файла. Для этого воспользуемся командой filter-branch:

$ git filter-branch --index-filter \
   'git rm --cached --ignore-unmatch git.tbz2' -- 6df7640^..
Rewrite 6df764092f3e7c8f5f94cbe08ee5cf42e92a0289 (1/2)rm 'git.tbz2'
Rewrite da3f30d019005479c99eb4c3406225613985a1db (2/2)
Ref 'refs/heads/master' was rewritten

Опция --index-filter похожа на --tree-filter, за исключением того, что вместо передачи команды, модифицирующей файлы на диске, мы используем команду, изменяющую файлы в индексе. Вместо удаления файла чем-то вроде rm file, стоит сделать это командой git rm --cached, так как нам надо удалить файл из индекса, а не с диска. Причина, по которой мы делаем именно так, — скорость. Нет необходимости извлекать каждую ревизию на диск, чтобы применить фильтр, а это может очень сильно ускорить процесс. Можете использовать и tree-filter для получения аналогичного результата, если хотите. Опция --ignore-unmatch команды git rm отключает вывод сообщения об ошибке в случае отсутствия файлов, соответствующих шаблону. И последнее, команда filter-branch переписывает историю, начиная с коммита 6df7640, потому что мы знаем, что именно с этого коммита появилась проблема. По умолчанию перезапись начинается с самого первого коммита, что потребовало бы гораздо больше времени.

Теперь наша история не содержит ссылок на данный файл. Однако, в reflog и в новом наборе ссылок, добавленном Git’ом в .git/refs/original после выполнения filter-branch, ссылки на него всё ещё присутствуют. Поэтому необходимо их удалить, а потом переупаковать базу. Необходимо избавиться от всех возможных ссылок на старые коммиты перед переупаковкой:

$ rm -Rf .git/refs/original
$ rm -Rf .git/logs/
$ git gc
Counting objects: 19, done.
Delta compression using 2 threads.
Compressing objects: 100% (14/14), done.
Writing objects: 100% (19/19), done.
Total 19 (delta 3), reused 16 (delta 1)

Посмотрим, сколько места удалось сохранить:

$ git count-objects -v
count: 8
size: 2040
in-pack: 19
packs: 1
size-pack: 7
prune-packable: 0
garbage: 0

Размер упакованного репозитория сократился до 7 КБ, что намного лучше, чем 2 МБ. Из значения поля size видно, что большой объект всё ещё хранится в одном из ваших “рыхлых” объектов, но, что самое важное, при любой последующей отправке данных наружу и в том числе при клонировании он передаваться не будет. Если очень хочется, можно удалить его навсегда локально, выполнив git prune --expire.

Итоги

Теперь вы довольно хорошо понимаете, что Git делает в фоне и, в некоторой степени, как он написан. В данной Лекции мы рассмотрели несколько служебных команд — простых команд, работающих на более низком уровне, чем обычные пользовательские команды, описанные в остальной части книги. Понимание принципов работы Git’а на низком уровне упрощает понимание работы Git’а в целом и даёт возможность написания собственных утилит и сценариев для организации специфического процесса работы с Git’ом.

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


Tags:
0 comments