Станьте профи в GIT, разобравшись в его архитектуре за 15 минут
Git может показаться сложной системой. Просто спросите Google. Вот несколько заголовков, которые выдаёт быстрый поиск:
Почему Git, чёрт возьми, такой трудный… Git просто слишком сложен… Может, хватит делать вид, что Git прост и легко изучается… Почему Git такой сложный и запутанный…
На первый взгляд эти утверждения могут показаться справедливыми, но как только вы поймёте лежащие в основе концепции, работа с Git станет одним удовольствием. Проблема Git в том, что он очень гибкий. А неотъемлемая черта любой гибкой системы — сложность. Я твёрдо убеждён, что единственный способ побороть эту сложность — спуститься к основам под предоставленным пользовательским интерфейсом и понять лежащую в основе мысленную модель и архитектуру. Как только вы это сделаете, никакой магии и неожиданных результатов не останется, и вы будете чувствовать себя как дома, работая с этими сложными инструментами.
Материал этой статьи будет полезен и разработчикам, которые уже работали с Git, и тем, кто только начинает работать с этим замечательным инструментом контроля версий. Если вы опытный пользователь GIT, вы гораздо лучше поймёте жизненный цикл checkout -> modify -> commit. Если вы только начинаете с Git, эта статья даст вам отличный старт.
В этом посте я буду использовать низкоуровневые, так называемые «сантехнические» (plumbing) команды, чтобы показать, как Git работает под капотом. Запоминать их не нужно: в обычной работе они применяются редко, но незаменимы при объяснении архитектуры Git.
Статья получилась довольно длинной, и я думаю, изучать её можно так:
- пробежаться по статье сверху вниз, чтобы понять общий ход изложения
- прочитать внимательно и повторять за мной, выполняя упражнения из статьи
Практикуясь, вы закрепите полученные здесь знания. В упражнениях я использую команды bash, так что если у вас ОС на базе Unix — всё в порядке. Если вы на Windows, можно запустить git-bash, который устанавливается вместе с Git и доступен через пункт контекстного меню Git Bash here.
Git как папка
Когда вы запускаете git init в папке, Git создаёт каталог .git. Откроем терминал, создадим новый каталог, в котором будем работать, и инициализируем там пустой репозиторий Git:
$ mkdir git-playground && cd git-playground
$ git init --object-format=sha1 --initial-branch=master
Initialized empty Git repository in path/to/git-playground/.git/
$ ls .git
HEAD config description hooks info objects refsВ обычном репозитории .git содержит базу объектов, ссылки, индекс и конфигурацию. Клонирование переносит нужные объекты и создаёт локальные ссылки и настройки, а не просто копирует все файлы исходного .git. Обычный клон также извлекает ветку, выбранную удалённым HEAD.
Git как база данных
Git — это простое хранилище «ключ-значение». Вы кладёте значение в репозиторий и получаете ключ, по которому это значение можно достать. Команда, которая кладёт значение в базу, — это hash-object, и она возвращает 40-символьную контрольную сумму, которая и будет использоваться как ключ. Команда создаёт в репозитории git простой объект, называемый blob. Запишем в базу простую строку f1 content:
$ F1CONTENT_BLOB_HASH=$( \
echo 'f1 content' | git hash-object -w --stdin
)
$ echo $F1CONTENT_BLOB_HASH
a1deaae8f9ac984a5bfd0e8eecfbafaf4a90a3d0Для тех, кто не очень знаком с шеллом: главная команда в приведённом фрагменте — это:
echo 'f1 content' | git hash-object -w --stdinКоманда echo выводит строку f1 content, а с помощью оператора конвейера | мы перенаправляем этот вывод в команду git hash-object. Параметр -w, переданный команде hash-object, велит ей сохранить объект; иначе команда просто скажет, каким был бы ключ. --stdin велит команде читать содержимое со стандартного ввода; если этого не указать, hash-object ожидает в конце путь к файлу. Как упоминалось выше, команда git hash-object возвращает хеш, который я затем сохраняю в переменную F1CONTENT_BLOB_HASH. Мы могли бы разделить основное выполнение и присваивание переменной вот так:
$ echo 'f1 content' | git hash-object -w --stdin
a1deaae8f9ac984a5bfd0e8eecfbafaf4a90a3d0
$ F1CONTENT_BLOB_HASH=a1deaae8f9ac984a5bfd0e8eecfbafaf4a90a3d0Но для удобства в дальнейших фрагментах кода я буду использовать краткую версию присваивания вывода в переменные. Эти переменные используются там, где ожидается строка хеша, а для чтения значения перед именем переменной ставится $.
Чтобы прочитать значение по ключу, можно воспользоваться командой cat-file с опцией -p. Команда ожидает хеш-ключ объекта, который нужно получить:
$ git cat-file -p $F1CONTENT_BLOB_HASH
f1 contentКак я говорил раньше, .git — это папка, и все сохранённые значения/объекты лежат в этой папке. Так что мы можем зайти в .git/objects и увидеть созданную Git папку с именем a1 — это первые два символа хеша:
$ ls .git/objects/ -l
a1/
info/
pack/Отдельные объекты хранятся как файлы в каталоге, названном по первым двум символам хеша. Один каталог может содержать несколько объектов. Git также объединяет объекты в pack-файлы в objects/pack. Идентификатор получается хешированием заголовка с типом и длиной вместе с содержимым, поэтому изменение содержимого даёт другой идентификатор.
Запишем в репозиторий ещё одну строку — f2 content:
$ F2CONTENT_BLOB_HASH=$( \
echo 'f2 content' | git hash-object -w --stdin )Как и ожидалось, вы можете видеть, что папка \.git\objects теперь содержит две записи: 9b/ и a1/:
$ ls .git/objects/ -l
9b/
a1/
info/
pack/Дерево как неотъемлемая часть
Сейчас в нашем репозитории два blob’а:
F1CONTENT_BLOB_HASH -> 'f1 content'
F2CONTENT_BLOB_HASH -> 'f2 content'Нам нужен способ как-то сгруппировать их вместе, а также связать каждый blob с именем файла. Вот здесь в игру и вступает дерево. Дерево можно создать командой git mktree, используя следующий синтаксис для каждой связки файл/blob:
[file-mode object-type object-hash file-name]
Объяснение файлового режима смотрите в этом ответе. Мы будем использовать режим 100644, который задаёт blob как обычный файл, доступный пользователю для чтения и записи. Эти права применяются при выгрузке файлов в рабочий каталог, чтобы правильно выставить права на файлы и каталоги.
Итак, чтобы связать два blob’а с двумя файлами, сделаем следующее:
$ INITIAL_TREE_HASH=$( \
printf '%s %s %s\t%s\n' \
100644 blob $F1CONTENT_BLOB_HASH f1.txt \
100644 blob $F2CONTENT_BLOB_HASH f2.txt |
git mktree )Как и в случае с hash-object, команда mktree возвращает хеш-ключ созданного объекта-дерева:
$ echo $INITIAL_TREE_HASH
e05d9daa03229f7a7f6456d3d091d0e685e6a9dbВот что у нас теперь есть:

После выполнения команды git создаёт в репозитории третий объект — типа tree. Посмотрим на него:
$ ls .git/objects -l
e0 <--- initial tree object (INITIAL_TREE_HASH)
9b <--- 'f2 content' blob (F2CONTENT_BLOB_HASH)
a1 <--- 'f1 content' blob (F1CONTENT_BLOB_HASH)При использовании mktree вместо blob’а в качестве параметра можно указать другой объект-дерево. Такое дерево будет связано с каталогом, а не с обычным файлом. Например, следующая команда создала бы дерево с поддеревом, связанным с каталогом nested-folder:
printf '%s %s %s\t%s\n' 040000 tree "$INITIAL_TREE_HASH" nested-folder | git mktreeФайловый режим 040000 помечает каталог, и мы используем тип tree вместо blob. Именно так git хранит вложенные каталоги в структуре проекта.
Индекс — место, где собираются деревья
Каждый, кто работает с GIT, должен быть знаком с понятием индекса, или области подготовки (staging area), и, вероятно, видел следующую схему:

Справа вы видите репозиторий git, в котором хранятся объекты git: blob’ы, деревья, коммиты и теги. Мы использовали команды hash-object и mktree, чтобы напрямую добавить в этот репозиторий объекты blob и tree. Рабочий каталог слева — это ваша локальная файловая система/каталог, куда вы выгружаете все файлы проекта. Этот раздел объясняет средний этап, который мы будем называть индексным файлом или просто индексом. Это бинарный файл (обычно лежит в .git/index), структура которого напоминает объект-дерево. Он содержит отсортированный список путей, каждый с правами доступа и SHA1 объекта blob/tree.
Это место, где git подготавливает дерево, прежде чем:
- записать его в репозиторий или
- выгрузить его в рабочий каталог
Сейчас у нас в репозитории есть одно дерево, созданное в предыдущей главе. Мы можем прочитать это дерево из репозитория в индексный файл командой read-tree:
$ git read-tree $INITIAL_TREE_HASHИтак, теперь мы ожидаем, что в индексном файле два файла. Проверить структуру текущего индексного файла можно командой git ls-files -s:
$ git ls-files -s
100644 a1deaae8f9ac984a5bfd0e8eecfbafaf4a90a3d0 0 f1.txt
100644 9b96e21cb748285ebec53daec4afb2bdcb9a360a 0 f2.txtПоскольку мы не вносили в индексный файл никаких изменений, он в точности совпадает с деревом, которым мы его заполнили. Раз в индексном файле правильная структура, восстановим файлы из него в рабочем каталоге командой checkout-index с опцией -a:
$ git checkout-index -a
$ ls
f1.txt f2.txt
$ cat f1.txt
f1 content
$ cat f2.txt
f2 contentОтлично! Мы только что выгрузили файлы, которые вручную добавили в репозиторий git, без каких-либо коммитов. Разве не круто?
Но индексный файл обычно не остаётся в состоянии начального дерева. Вы, вероятно, знаете, что его можно изменять командами git add [file path] и git rm --cached [file path] для отдельного файла или git add -A и git reset для набора изменённых/удалённых файлов. Применим это знание на практике и создадим в репозитории новое дерево, содержащее один файловый blob, связанный с новым текстовым файлом f3.txt. Содержимым файла будет строка f3 content. Но вместо того чтобы создавать дерево вручную, как мы делали в предыдущем разделе, воспользуемся для этого индексным файлом.
Сейчас структура индексного файла такова — она основана на дереве initial tree, которым мы заполнили индекс:

Изменения индекса остаются подготовленными, пока вы не создадите коммит или не отмените их. git add сразу записывает содержимое в объекты, поэтому перезаписанную подготовленную версию иногда можно восстановить до удаления недостижимых объектов. Правки, которые никогда не добавлялись в индекс и не коммитились, Git не хранит. Сборка мусора — обслуживание репозитория, а не гарантированный срок восстановления.
Начнём с удаления двух файлов в рабочем каталоге:
$ rm f1.txt f2.txtЕсли теперь выполнить git status, увидим следующее:
$ git status
On branch master
Initial commit
Changes to be committed:
(use "git rm --cached <file>..." to unstage)
new file: f1.txt
new file: f2.txt
Changes not staged for commit:
(use "git add/rm <file>..." to update what will be committed)
(use "git checkout -- <file>..." to discard changes in working directory)
deleted: f1.txt
deleted: f2.txtЭто довольно много информации. Сообщается о двух удалённых файлах и двух новых, а также говорится «initial commit». И вот почему. Когда вы выполняете git status, git делает два сравнения:
- сравнивает индексный файл с текущим рабочим каталогом — изменения сообщаются как «not staged for commit»
- сравнивает индексный файл с коммитом HEAD — изменения сообщаются как «to be committed»
Итак, в нашем случае мы видим, что git сообщает о двух удалённых файлах в разделе «Changes not staged for commit», и мы знаем, откуда он это взял: он сравнил текущий рабочий каталог с индексным файлом и обнаружил, что двух файлов в рабочем каталоге не хватает (потому что мы их удалили).
Git показывает файлы индекса как новые, потому что в репозитории ещё нет коммитов. HEAD указывает на ветку без первого коммита и не разрешается в коммит. Для статуса Git сравнивает индекс с пустым деревом; сам файл HEAD на это дерево не указывает.
Теперь, если выполнить git add ., это изменит индексный файл, удалив из него два файла, и когда мы снова выполним git status, он не сообщит ни о каких изменениях, поскольку файлов нет ни в рабочем дереве, ни в индексном файле:
$ git add .
$ git status
On branch master
Initial commit
nothing to commit (create/copy files and use "git add" to track)Мы начинали с задачи создать новое дерево с одним новым файлом f3.txt. Создадим этот файл и добавим его в индекс:
$ echo 'f3 content' > f3.txt
$ git add f3.txtЕсли теперь выполнить git status:
$ git status
On branch master
Initial commit
Changes to be committed:
(use "git rm --cached <file>..." to unstage)
new file: f3.txtВидим, что обнаружен один новый файл. Опять же, изменения сообщаются в разделе «to be committed», значит теперь Git сравнил индексный файл с «пустым деревом». Так что мы ожидаем, что в индексном файле будет этот один новый файловый blob. Проверим:
$ git ls-files -s
100644 5927d85c2470d49403f56ce27afd8f74b1a42589 0 f3.txt
# Сохраняем хеш blob'а файла f3.txt
$ F3CONTENT_BLOB_HASH=5927d85c2470d49403f56ce27afd8f74b1a42589Отлично, теперь у индекса правильная структура, и мы готовы создать из него дерево в репозитории. Сделаем это командой write-tree:
$ LATEST_TREE_HASH=$( git write-tree )Здорово, мы только что создали дерево с помощью индекса. И положили хеш нового дерева в переменную LATEST_TREE_HASH. Мы могли бы сделать это вручную, записав blob f3 content в репозиторий и затем создав дерево через mktree, но с индексом гораздо удобнее.
Интересно, что если сейчас выполнить git status, вы всё ещё увидите, что git по-прежнему считает файл f3.txt новым:
$ git status
On branch master
Initial commit
Changes to be committed:
(use "git rm --cached <file>..." to unstage)
new file: f3.txtwrite-tree сохранила объект, но не создала коммит и не переместила HEAD. Он всё ещё указывает на ветку без первого коммита, поэтому подготовленный файл по-прежнему считается новым.
Итак, с этим новым деревом в репозитории у нас следующие объекты:

Коммит — это обёртка вокруг дерева
В этом разделе всё становится ещё интереснее. В повседневной работе с Git мы обычно не сталкиваемся с деревьями или blob’ами. Мы работаем с объектами-коммитами. Так что же такое коммит в git? На самом деле, если совсем просто, это всего лишь обёртка вокруг объекта-дерева, которая:
- позволяет прикрепить к дереву (группе файлов) сообщение
- позволяет указать родителя (коммит)
Сейчас в нашем репозитории git два дерева — initial tree и latest tree. Обернём первый объект-дерево в коммит командой commit-tree, которая принимает хеш дерева, для которого создаётся коммит:
Перед созданием коммитов убедитесь, что в Git настроены user.name и user.email. Хеши ваших коммитов будут отличаться от примеров, поскольку коммиты содержат сведения об авторе и временные метки.
$ INITIAL_COMMIT_HASH=$( \
echo 'initial commit' | git commit-tree $INITIAL_TREE_HASH
)После выполнения приведённой выше команды у нас будет следующее:

И мы можем выгрузить этот коммит в рабочий каталог:
$ git checkout $INITIAL_COMMIT_HASH
A f3.txt
HEAD is now at a27a75a... initial commitCheckout восстанавливает f1.txt и f2.txt, но также сохраняет подготовленный f3.txt, поскольку он не конфликтует с целевым коммитом. Теперь уберите этот учебный файл из индекса и рабочего каталога; его содержимое и дерево уже сохранены в базе объектов:
$ git rm -f f3.txt
rm 'f3.txt'
$ ls
f1.txt f2.txt
$ cat f1.txt
f1 content
$ cat f2.txt
f2 contentКогда вы выполняете git checkout [commit-hash], git делает следующее:
- читает дерево, на которое указывает коммит, в индексный файл
- восстанавливает файлы из индекса в рабочем каталоге
- обновляет файл HEAD хешем коммита
Это те самые операции, которые мы вручную выполняли в предыдущем разделе.
История Git — это цепочка коммитов
Итак, теперь мы знаем, что коммит — просто обёртка вокруг дерева. Я также упоминал, что у него может быть родительский коммит. Изначально у нас было два дерева, и в предыдущем разделе мы обернули одно из них в коммит, так что одно «сиротское» дерево у нас ещё осталось. Обернём и его в коммит той же операцией commit-tree, что и выше, но с опцией -p, чтобы указать родителя:
$ LATEST_COMMIT_HASH=$( \
echo 'latest commit' |
git commit-tree $LATEST_TREE_HASH -p $INITIAL_COMMIT_HASH )И вот что у нас теперь:

Теперь, если выполнить git log, чтобы посмотреть историю, и передать хеш коммита «latest», вы увидите два коммита:
$ git log --pretty=oneline $LATEST_COMMIT_HASH
[some hash] latest commit
[some hash] initial commitИ мы можем переключаться между ними. Вот начальный коммит:
$ git checkout $INITIAL_COMMIT_HASH
$ ls
f1.txt f2.txtПоследний коммит:
$ git checkout $LATEST_COMMIT_HASH
$ ls
f3.txtHEAD — это ссылка на выбранный коммит
HEAD — это простой текстовый файл, расположенный по пути .git/HEAD, который ссылается на текущий выбранный коммит. Поскольку в предыдущем разделе мы выгрузили коммит «latest» с хешем $LATEST_COMMIT_HASH, именно это и содержит файл HEAD:
$ cat .git/HEAD
88d3b9901d62fc1de9219f388e700d98bdb97ba9
$ [ "$LATEST_COMMIT_HASH" = "$(git rev-parse HEAD)" ] && echo 'equal'
equalОднако обычно файл HEAD ссылается на текущий выбранный коммит через ссылки на ветки. Когда он ссылается на коммит напрямую, он находится в отсоединённом состоянии (detached state). Но даже когда HEAD содержит ссылку на ветку вот так:
ref: refs/heads/masterон всё равно разрешается в хеш коммита.
Вы уже знаете, что Git использует коммит, на который ссылается HEAD, при выполнении git status, чтобы получить набор изменений между индексным файлом и текущим выбранным деревом/коммитом. Другое применение HEAD — определить коммит, который станет родителем для будущего коммита.
Любопытно, что файл HEAD настолько важен для большинства операций, что если вручную очистить его содержимое, Git решит, что это не репозиторий git, и сообщит об ошибке:
fatal: Not a git repository (or any of the parent directories): .gitВетка — это текстовый файл, указывающий на коммит
Итак, теперь в нашем репозитории два коммита, которые составляют следующую историю:

$ git log --pretty=oneline $LATEST_COMMIT_HASH
[some hash] latest commit
[some hash] initial commitВнесём в существующую историю развилку. Мы выгрузим начальный коммит и изменим содержимое файла f1.txt. После этого сделаем новый коммит привычной вам командой git commit:
$ git checkout $INITIAL_COMMIT_HASH
$ echo 'I am modified f1 content' > f1.txt
$ git add f1.txt
$ git commit -m "forked commit"
1 file changed, 1 insertion(+), 1 deletion(-)Приведённый фрагмент кода:
- выгружает
"initial commit", который добавляетf1.txtиf2.txtв рабочий каталог - заменяет содержимое
f1.txtстрокойI am modified f1 content - обновляет индексный файл через
git add
Последняя команда git commit, как мы уже знаем, выполняет несколько операций под капотом:
- создаёт дерево из индексного файла
- записывает это дерево в репозиторий
- создаёт объект-коммит, который оборачивает это дерево
- назначает
initial commitродителем нового коммита, поскольку это тот коммит, который сейчас лежит в файлеHEAD
Нам также нужно сохранить хеш этого коммита в переменную. Поскольку Git обновляет HEAD текущим коммитом, мы можем прочитать его оттуда:
FORKED_COMMIT_HASH=$( cat .git/HEAD )Итак, теперь в нашем репозитории git следующие объекты:

Что создаёт следующую историю коммитов:

Из-за появления развилки у нас здесь две линии работы. А значит, нужно ввести две ветки, чтобы отслеживать каждую линию независимо. Создадим ветку master, которая отслеживает линейную историю, начиная с latest commit, и ветку forked, которая отслеживает историю от forked commit.
Ветка — именованная ссылка на коммит. Для отдельных ссылок это текстовый файл в .git/refs/heads; ссылки также могут быть упакованы или храниться другим способом. В этом новом репозитории запишем идентификаторы коммитов напрямую, чтобы показать устройство отдельных ссылок. В обычных скриптах используйте git branch или git update-ref.
Вот это будет указывать на основную ветку с «latest commit»:
$ echo $LATEST_COMMIT_HASH > .git/refs/heads/masterА это будет указывать на ветку «forked» с «forked commit»:
$ echo $FORKED_COMMIT_HASH > .git/refs/heads/forked
Итак, мы наконец добрались до обычного рабочего процесса, к которому вы привыкли, — теперь мы можем переключаться между ветками:
$ git checkout master
Switched to branch 'master'
$ git log --pretty=oneline
[some hash] latest commit
[some hash] initial commit
$ ls -l
f3.txtА теперь посмотрим на другую ветку, forked:
$ git checkout forked
Switched to branch 'forked'
$ git log --pretty=oneline
[forked commit hash] forked commit
[initial commit hash] initial commit
$ ls
f1.txt f2.txt
$ cat f1.txt
I am modified f1 contentТег — это текстовый файл, указывающий на коммит
Вы, вероятно, знаете, что вместо линии работы, которую мы отслеживаем ветками, можно отслеживать отдельные коммиты с помощью тегов. Теги обычно используют, чтобы отмечать важные вехи разработки, например релизы. Прямо сейчас в нашем репозитории 3 коммита. И мы можем дать им имена с помощью тега. Как и ветка, тег — это текстовый файл, содержащий хеш коммита, и он входит в группу ссылок git.
Как вы уже знаете, все ссылки git хранит в папке .git/refs, а теги — во вложенной папке .git/refs/tags. Поскольку это простой текстовый файл, мы можем создать файл и положить в него хеш коммита.
Этот тег указывает на коммит из ответвления:
$ echo $FORKED_COMMIT_HASH > .git/refs/tags/forkedА это — на начальный коммит:
$ echo $INITIAL_COMMIT_HASH > .git/refs/tags/initialКак только мы это сделали, можно переключаться между коммитами с помощью тегов. Вот начальный коммит:
$ git checkout tags/initial
HEAD is now at [initial commit hash]... initial commit
$ cat f1.txt
f1 contentА вот коммит с развилки:
$ git checkout tags/forked
$ cat f1.txt
I am modified f1 contentСуществует также «аннотированный тег», который отличается от этого легковесного. Это настоящий объект, который может содержать сообщение, как и коммит, и хранится в репозитории наряду с другими объектами.
Заключение
Статья получилась довольно длинной, но я старался сделать её как можно понятнее и прозрачнее. Как только вы проработаете её и разберётесь во всех показанных здесь концепциях, вы сможете работать с Git гораздо эффективнее и вам больше не придётся бояться неожиданных результатов.
Если хотите узнать о Git больше, я очень рекомендую эту замечательную книгу, которая к тому же бесплатно доступна онлайн.