✏️ ️Посты 🌍 Путешествия Подписаться 👍 Донат
🔍
👤
Git снизу вверх. Часть 1
19 августа 2012 — 4 комментария — 24623 просмотра — 1812 слов

Решил сделать хоть что-то полезное последнее время. Потому буду писать про внутреннее устройство git. Никаких «как создать коммит и запушить на сервер», только хардкор. Спасибо книжке «Git from bottom up», посты будут являться ее вольным переводом. Или пересказом, кому как приятнее.

Введение

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

repository
Репозиторий — коллекция коммитов, каждый из которых в свою очередь представляет собой вид рабочего дерева (working tree) на момент совершения коммита. В репозитории так же есть HEAD — символическая ссылка на ветвь (branch) или определенный коммит, над которыми сейчас ведется работа.
<dt>the index (staging area)</dt>
<dd>В отличии от многих других систем контроль версий, git не сразу вносит все ваши изменения в свой репозиторий. Вместо этого они сначала помещаются в так называемый индекс, которые многие называют staging area (из-за смежной команды в git). Грубо говоря индекс - это место, в котором накапливаются изменения до коммита.</dd>

<dt>working tree</dt>
<dd>Долго думал над более литературным переводом этого термина, решил остановиться на "рабочем дереве". Рабочее дерево — это любая директория с поддиректориями, для которой создан git-репозиторий (короче в ней лежит папочка .git).</dd>

<dt>commit</dt>
<dd>Коммит — снимок рабочего дерева в определенное время.</dd>

<dt>branch</dt>
<dd>Ветвь — ссылка на определенный коммит. Почему это определение такое простое, станет понятно дальше. Просто git следит за ее изменением с каждым новым коммитом.</dd>

<dt>tag</dt>
<dd>Тег, как и ветвь, так же просто имя (ссылка) коммита, имеющая еще пару параметров типа описаний.</dd>

<dt>master</dt>
<dd>Название по-умолчанию для главной ветви разработки.</dd>

<dt>HEAD</dt>
<dd>Указывает на корень дерева коммитов, с которым вы сейчас работаете. Если сделан checkout на ветвь — HEAD указывает на последний коммит в этой ветви (с каждым новым коммитом передвигается на него), если checkout на коммит (или тег), то HEAD указывает всегда только на него. Чаще всего такое хамство является следствием оторванной головы (хе-хе, каламбурчик). Как у автора, так и у репозитория. Detached HEAD — очень болезненная штука, если ее не уследить.</dd>

В общем обычное использование git выглядит так: после создания репозитория работа происходит в working tree, когда работа доходит до определенной отметки — вы добавляете изменения в индекс (делаете git add), когда индекс содержит всё нужное - создается коммит (git commit). А если вы делаете checkout, то в индекс помещается содержимое того коммита, на который вы его сделали. Если в нем что-то есть, git скажет об этом и предложит что-то сделать. Либо создать коммит, либо похерить. Всё просто. Я даже не буду переводить картинку, это и так очевидно.

Файловая система git

Лично мне первое время было очень интересно, как же происходит вся эта магия. Как человек привыкший к терминам файлов и директорий, знаю устройство файловой системы. Что есть такая абстракция как директории, являющиеся узлами дерева, хранящие некоторую метаинформацию, так же есть i-ноды, являющиеся адресами файлов на жестком диске и листьями этого дерева. На одну i-ноду в UNIX может быть несколько жестких ссылок, то есть один файл может лежать в нескольких директориях. Это просто и всем известно.

К чему я это? Git имеет схожую структуру, за исключением двух ключевых отличий. Первое — файлы представляются в виде blob'ов, то есть аналог i-ноды в git - это имя этого блоба. Забегая вперед, имя - это SHA1 от хеша содержимого и размера файла (опять каламбурчик). В общем имя блоба это почти i-нода за исключением двух вещей: первое — содержимое файла никогда не изменяется (иначе меняется его SHA1, очевидно), второе — файлы с одинаковым размером и содержимым дают нам одинаковый blob. Так что если несколько деревьев ссылается на одинаковые файлы - это как хард-линк. Blob в итоге один. Подумав чуть дальше, читатель может догадаться о трюке с подменой, например blob'ов из разных репозиториев.

Так же blob не хранит никаких метаданных, вся метаинформация о нем хранится в дереве. Таким образом один и тот же blob может входить в одно дерево как файл foo, созданный 20 августа, так же как и в другое дерево как файл bar, созданный 5 лет назад. Помните ведь, что blob не хранит информацию о своем названии? Такое различие обусловлено тем, что файловая система хранит файлы, которые могут изменяться, а blob'ы в git не могут. Такая система имеет свои плюсы и минусы, неизменяемые объекты проще обрабатывать и передавать, но отсюда так же вытекает всем известная нелюбовь git в большим бинарным файлам.

Познакомимся с BLOB

После этого небольшого введения пора перейти к более интересным вещам. Начнем с создания пары файлов, чтобы поиграть с ними.

$ mkdir sample; cd sample
$ echo 'Hello, world!' > greeting

На данный момент я только создал директорию sample и один файл в ней. Я еще не создал репозиторий, но уже сейчас хочу использовать пару команд git, например, узнать имя захешированного файла (blob'а), который бы получился из нашего.

$ git hash-object greeting
af5626b4a114abcb82d63db7c8082c3c4756e51b

Можете проверить, запустив эту команду на вашей операционной системе, вы должны получить тот же самый hash id. Даже если вы создадите несколько репозиториев, этот id будет одинаковым везде. То, что я и говорил во введении.

Следующим шагом создадим, наконец, репозиторий и сделаем коммит.

$ git init
$ git add greeting
$ git commit -m "Added my greeting"

Наш blob должен быть на месте, проверим это. Кстати git нужно всего-лишь первые 6-7 знаков id.

$ git cat-file -t af5626b
blob
$ git cat-file blob af5626b
Hello, world!

Да, все как мы и ожидали. Мы не смотрели какой коммит или дерево содержит этот blob, мы просто обратились к его содержимому по его id. На самом деле для начала уже хорошо. Ведь весь git строится на blob'ах. Всё, чем он занимается - таскает их по деревьям.

Деревья

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

$ git ls-tree HEAD
100644 blob af5626b4a114abcb82d63db7c8082c3c4756e51b greeting

Да, это оно. Это дерево из одного листа — нашего blob'а. Но мы до сих пор не видим дерево, содержащие наш коммит. Для этого нужно проделать еще несколько манипуляций.

$ git rev-parse HEAD
c4987cef0592d02b3fa784ce7757f0682986e0e6 (этот id всегда разный)
$ git cat-file -t HEAD
commit
$ git cat-file commit HEAD
tree
0563f77d884e4f79ce95117e2d686d7d6e282887
author vas3k <[email protected]> 1345382199 +0700
committer vas3k <[email protected]> 1345382199 +0700

Added my greeting

Первые две команды показывают нам, что HEAD — это действительно всего лишь ссылка на коммит. Первая декодирует HEAD как реальный id коммита, вторая - отображает его тип. Id этого коммита уже будет отличаться у вас, так как он генерируется от даты и времени создания. Этот id всегда уникален в пределах одного репозитория. И по нему мы снова можем посмотреть дерево blob'ов, которое содержит этот коммит.

$ git ls-tree 0563f77
100644 blob af5626b4a114abcb82d63db7c8082c3c4756e51b greeting

Так что мы имеем. У нас есть репозиторий, содержащий 1 коммит, который содержит дерево, которое содержит 1 blob. В этом можно удостовериться, заглянув в .git/objects или использовав еще раз cat-file.

$ find .git/objects -type f | sort
.git/objects/05/63f77d884e4f79ce95117e2d686d7d6e282887
.git/objects/af/5626b4a114abcb82d63db7c8082c3c4756e51b
.git/objects/c4/987cef0592d02b3fa784ce7757f0682986e0e6

$ git cat-file -t 0563f77d884e4f79ce95117e2d686d7d6e282887
tree
$ git cat-file -t af5626b4a114abcb82d63db7c8082c3c4756e51b
blob
$ git cat-file -t c4987cef0592d02b3fa784ce7757f0682986e0e6
commit

Глубже

Каждый коммит содержит дерево, но как эти деревья создаются? С blob'ами разобрались, но как создать дерево руками? Для начала снесем к чертям всё, что сделали и создадим мир заново.

$ rm -fr greeting .git
$ echo 'Hello, world!' > greeting
$ git init
$ git add greeting

Помните про индекс? На данный момент всё, что мы сделали - добавили файл в индекс. Нет еще ни деревьев, ни коммитов. Об этом говорит нам лог (он наебнется из-за отсутствия коммитов).

$ git log
fatal: bad default revision 'HEAD'

Но самые любопытные из вас, наверное, уже заглянули снова в папочку objects.

$ git ls-files --stage
100644 af5626b4a114abcb82d63db7c8082c3c4756e51b 0 greeting

Срань господня, что это? Я не коммитил еще ничего, а уже какой-то подозрительный blob с до боли знакомым id появился у нас в stage. Кто не верит, может снова скормить этот id в cat-file или еще как-либо удостовериться. Еще можно удостовериться, что это действительно индекс, посмотрев содержимое .git/index. А мы пока пойдем дальше. Мы там хотели строить деревья. Для записи содержимого индекса в дерево есть специальная команда write-tree.

$ git write-tree
0563f77d884e4f79ce95117e2d686d7d6e282887

Этот id'шник нам тоже очень знаком, не правда ли? Это значит, что деревья, содержащие одинаковые blob'ы и поддеревья, имеют одинаковые id. Но мы еще не создали коммит, мы только сделали дерево из содержимого индекса и никуда его не прикрепили. Забегая вперед скажу, что если бросить созданное дерево на произвол судьбы, git отметит его как недоступное (unreachable), то есть мусор. При следующем коммите это дерево будет стерто сборщиком мусора (git gc, можете проверить).

$ echo "Initial commit" | git commit-tree 0563f77
1ba0b26a62a52bfccb69c25e4259521ec65b4f25

Команда commit-tree как раз и делает то, что нам нужно. Она берет созданное дерево и делает объект-коммит, который его содержит. С помощью опции -p так же можно привязать коммит к родителю, но всё по-порядку. А пока заметим, что id коммита снова другой, ведь он зависит от времени, не забыли? Наша работа еще не закончена, нужно еще зарегистрировать коммит как корень текущего дерева.

$ echo 1ba0b26a62a52bfccb69c25e4259521ec65b4f25 > .git/refs/heads/master

Эта хамская команда говорит git о новом коммите совершенно по-наглому. Более безопасный путь это сделать был бы:

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

Но мы привыкли. После создания главной ветви (master) мы должны еще переместить указатель HEAD на последний коммит в ней. Мы ведь помним про оторванную голову?

$ git symbolic-ref HEAD refs/heads/master

Сложно поверить, но мы только что покакали через рот создали новый коммит.

$ git log
commit 1ba0b26a62a52bfccb69c25e4259521ec65b4f25
Author: vas3k <[email protected]>
Date: Sun Aug 19 20:57:06 2012 +0700

Initial commit

Красота коммитов

Обычно системы контроля версий и особенно маны по ним выставляют ветви (branches) как что-то прекрасное и волшебное, обсуждают их как что-то совершенно отдельное и более высокоуровневое, чем коммиты. В git любая ветвь - это просто коммиты. Много коммитов, связанных между собой отношением сын-родитель. Когда один коммит имеет более одного родителя - это merge, имеет больше одного сына - branch, вот и все. На нашем уровне не существует тегов, бранчей и мержей, есть только коммиты. Ветвь - ничто иное, как ссылка на коммит. Тег не отличается от ветви ничем, кроме того, что имеет описание. Посмотреть дерево коммитов можно командой git branch -v. Конечно, когда ветвей несколько, вывод команды немного более интересный, но у нас пока только один коммит.

$ git branch -v
* master 1ba0b26 Initial commit

На самом деле нам не нужны ссылки вообще. Можно обратиться к любой части дерева просто по id. Например, можно переключиться на другую ветвь командой:

$ git reset --hard 1ba0b26

Здесь --hard означает похерить все текущие изменения. Безопасным аналогом этой команды является известный всем git checkout 1ba0b26. Разница еще состоит в том, что без ключа -f изменения не будут похерены.

<img src="https://i.vas3k.blog/full/4j9.png" alt=""style="width: 600px;" />

Понимание системы коммитов - ключ к пониманию устройства git. Когда вы перестанете мыслить ветвями, слияниями и тегами, а в голове будут только коммиты и отношения между ними — вы постигните Дзен. А в следующий раз разберем подробнее как создавать деревья коммитов, то есть ручной branch и merge. Конечно, если у меня будет желание и если хоть кому-то кроме меня это нужно. Пишите комментарии.

Домашнее чтение



Комментировать
Комментарии 👇
themylogin 19 августа 2012 в 17:08 #
1

Жёстко:(

ReDetection 21 августа 2012 в 13:26 #
0

хорошо, что я эту книжку не читал. как-то сумбурно описано. не понимал бы до прочтения - вряд ли бы нашел силы понять этот текст

Bad style 27 ноября 2015 в 09:22 #
0

Дельно и чётко описано.

Вадек 05 октября 2018 в 20:50 #
0

Статья топчик, похожа на ту статью из хабра про "гит снизу вверх", но как-то более на легке зашла. Орнул в голосину пару раз тоже, за это отдельное спасибо :)

Еще? Тогда вот