Большой двоичный объект, дерево и коммиты

Когда я начал использовать Git, я делал то же, что и большинство людей. Я запоминал команды, чтобы выполнить работу, не понимая, что происходит под капотом. В большинстве случаев я получал желаемые результаты. Но я все еще был разочарован тем, что время от времени «ломал» репозиторий, приводя его в состояние, которого не ожидал, и не зная, как это исправить.

Ваш опыт похож?

Упрощенный подход к использованию репозитория — это попытка использовать инструмент, не выполняя основную домашнюю работу, чтобы узнать, как он работает. В моем случае все «щелкнуло», как только я прочитал о внутренней модели данных, используемой Git. Понимаете, Git — это своего рода база данных, и никто никогда не сможет работать с SQL, например, не зная, что такое таблица, запись и т. д. Давайте восполним пробелы в знаниях и немного посмотрим на внутренности репозитория Git.

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

Сразу после создания репозитория с помощью git init вы найдете внутри:

$ ls -R .git
HEAD            config          description     hooks           info            objects         refs

.git/hooks:
applypatch-msg.sample           pre-applypatch.sample           pre-rebase.sample               update.sample
commit-msg.sample               pre-commit.sample               pre-receive.sample
fsmonitor-watchman.sample       pre-merge-commit.sample         prepare-commit-msg.sample
post-update.sample              pre-push.sample                 push-to-checkout.sample

.git/info:
exclude

.git/objects:
info    pack

.git/objects/info:

.git/objects/pack:

.git/refs:
heads   tags

.git/refs/heads:

.git/refs/tags:

Сейчас там почти пусто: у нас есть несколько папок, в основном это примеры файлов для хуков. Мы проигнорируем их; основное внимание в этой статье мы уделим .git/objects содержимому — основному хранилищу данных в Git.

Капли

Git хранит каждую версию каждого отслеживаемого файла в виде большого двоичного объекта. Git идентифицирует большие двоичные объекты по хешу их содержимого и хранит их в .git/objects. Любое изменение содержимого файла приведет к созданию совершенно нового объекта большого двоичного объекта.

Самый простой способ создать объект — добавить его на стадию. То, что находится на стадии, станет частью следующего коммита. Staging — это состояние «до фиксации» в git. Здесь мы храним файлы, которые еще не зафиксированы, но уже отслеживаются Git.

Пример

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

$ echo "Test" > test.txt

Этой командой мы записываем «Test» в файл test.txt. Чтобы сделать его большим двоичным объектом, нам просто нужно добавить его на сцену, выполнив:

$ git add .

После добавления нашего нового файла на сцену внутри .git/objects мы имеем:

$ ls -R .git/objects
34      info    pack

.git/objects/34:
5e6aef713208c8d50cdea23b85e6ad831f0449

.git/objects/info:

.git/objects/pack:

У нас есть новая папка 34, а внутри этой папки файл 5e6aef713208c8d50cdea23b85e6ad831f0449. Это связано с тем, что хэш содержимого равен 345e....: два символа спереди используются как каталог. Содержимое этого файла:

$ cat .git/objects/34/5e6aef713208c8d50cdea23b85e6ad831f0449
xKOR0I-.

Он сжат для повышения эффективности хранения. Мы можем увидеть, что внутри, выполнив следующую команду Git:

$ git cat-file blob 345e6aef713208c8d50cdea23b85e6ad831f0449
Test

У нас есть только контент внутри — метаданных для файла нет.

Пример модификации

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

$ echo "Test 2" >> test.txt

Эта команда добавляет новую строку «Тест 2» в существующий файл test.txt.

Добавим текущую версию на сцену:

$ git add .

И посмотрим, что у нас внутри папки .git/objects:

$ ls -R .git/objects
34      d2      info    pack

.git/objects/34:
5e6aef713208c8d50cdea23b85e6ad831f0449

.git/objects/d2:
77ba2806ce99d418b0b5d6c28643deca0e36dc

...

Теперь у нас есть два объекта, второй внутри подпапки d2. Его содержание:

$ git cat-file blob d277ba2806ce99d418b0b5d6c28643deca0e36dc
Test
Test 2

Он такой же, как у нашего обновленного text.txt:

$ cat test.txt
Test
Test 2

Как мы видим, Git хранит полный файл для каждой версии.

Дерево

Объекты дерева — это то, как Git хранит папки. Они ссылаются на другие вещи как на свое содержание:

  • файлы добавляются их блобом
  • подпапки добавляются по их дереву

Для каждой ссылки в дереве хранится:

  • имя файла или папки
  • блоб или хэш дерева
  • тип объекта
  • разрешения

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

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

Создание дерева

Обычно вы создаете дерево как часть коммита. Мы рассмотрим коммиты позже в этой статье, а пока давайте воспользуемся git write-tree-командой plumbing, которая создает дерево на основе того, что находится внутри нашей подготовки.

Команды сантехники и фарфора происходят из аналогии, используемой в Git:

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

Если вы не занимаетесь продвинутыми вещами, вам не нужно знать команды сантехники.

Пример

С нашей постановкой, как и раньше, мы запускаем:

$ git write-tree
fd4f9947de2805e460bfeeca3346e3d36d617d37

Возвращаемое значение — это идентификатор нашего нового объекта дерева. Чтобы заглянуть внутрь, вы можете запустить:

$ git cat-file -p fd4f9947de2805e460bfeeca3346e3d36d617d37
100644 blob d277ba2806ce99d418b0b5d6c28643deca0e36dc    test.txt

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

$ ls -R .git/objects
34      d2      fd      info    pack

.git/objects/34:
5e6aef713208c8d50cdea23b85e6ad831f0449

.git/objects/d2:
77ba2806ce99d418b0b5d6c28643deca0e36dc

.git/objects/fd:
4f9947de2805e460bfeeca3346e3d36d617d37

…

Все данные находятся в одной структуре папок.

Вложенный пример

Теперь добавим внутрь еще одну папку, чтобы посмотреть, как хранятся вложенные деревья:

# create a new folder
$ mkdir nested #
# add a file & it’s content
$ echo 'lorem' > nested/ipsum
# adding it to the stage
$ git add .

Создание дерева сейчас даст нам новый ID:

$ git write-tree
25517090ae5d0eb08f694de6d38d613615fe99e4

Его содержание:

$ git ls-tree 25517090ae5d0eb08f694de6d38d613615fe99e4
040000 tree bc9a36d27aa303a3b1cab543b64c6944fea5ce8b    nested
100644 blob d277ba2806ce99d418b0b5d6c28643deca0e36dc    test.txt

Мы видим, что nested было добавлено в качестве ссылки на дерево. Посмотрим, что внутри:

$ git ls-tree bc9a36d27aa303a3b1cab543b64c6944fea5ce8b
100644 blob 3e9ffe066cd7b2ce4c6fb5c8f858496194e1c251    ipsum

Как видите, это еще один древовидный объект, описывающий содержимое папки. Имея множество древовидных объектов, вы можете описать любую вложенную структуру папок.

Коммиты

Коммит — это полное описание состояния репозитория. Он содержит следующую информацию:

  • ссылка на объект дерева, описывающий самую верхнюю папку
  • автор фиксации, коммиттер и время
  • родительский коммит(ы) — коммиты, на которых мы основывали этот коммит

Большинство коммитов имеют только одного родителя, за следующими исключениями:

  • первый коммит в истории не имеет родителей
  • коммиты слияния имеют более одного

Как и прежде, Git идентифицирует каждый коммит по хешу его содержимого. Таким образом, любое изменение файлов, папок или метаданных фиксации приведет к созданию новой фиксации.

Первый коммит

Мы можем создать нашу первую фиксацию с помощью стандартной команды фиксации:

$ git commit -m 'first commit'
[main (root-commit) 26349a2] first commit
 2 files changed, 3 insertions(+)
 create mode 100644 nested/ipsum
 create mode 100644 test.txt

Вывод показывает усеченный идентификатор фиксации. Найдем полное значение:

$ git show
commit 26349a25253f9b316db1a5d3c3f23c1ca5ca4e0e (HEAD -> main)
Author: Marcin Wosinek <[email protected]>
Date:   Thu Apr 28 18:18:07 2022 +0200

    first commit
…

Чтобы увидеть содержимое объекта фиксации, мы можем использовать:

$ git cat-file -p 26349a25253f9b316db1a5d3c3f23c1ca5ca4e0e
tree 25517090ae5d0eb08f694de6d38d613615fe99e4
author Marcin Wosinek <[email protected]> 1651162687 +0200
committer Marcin Wosinek <[email protected]> 1651162687 +0200

first commit

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

$ ls -R .git/objects
25      26      34      3e      bc      d2      fd      info    pack

…

.git/objects/26:
349a25253f9b316db1a5d3c3f23c1ca5ca4e0e

…

Следующая фиксация

Давайте восстановим первую версию нашего файла test.txt:

$ echo "Test" > test.txt

Эта команда перезаписывает существующий файл на «Test».

$ git add .

Добавляет обновленную версию в staging.

$ git commit -m 'second commit'
[main 7f54a43] second commit
 1 file changed, 1 deletion(-)

Фиксирует изменения.

Найдем полный ID:

$ git show
commit 7f54a437d87cd1f241cfb893c4823bc7e60c19ec (HEAD -> main)
Author: Marcin Wosinek <[email protected]>
Date:   Thu Apr 28 18:37:55 2022 +0200

    second commit
…

Таким образом, содержимое коммита:

$ git cat-file -p 7f54a437d87cd1f241cfb893c4823bc7e60c19ec
tree 04b0192c1c88ac1c1a96f386e84e5388ef8a509a
parent 26349a25253f9b316db1a5d3c3f23c1ca5ca4e0e
author Marcin Wosinek <[email protected]> 1651163875 +0200
committer Marcin Wosinek <[email protected]> 1651163875 +0200

second commit

Git добавил строку parent, потому что мы фиксируем поверх другого коммита.

Другие важные данные, хранящиеся в Git, — это просто ссылки на самый последний коммит. Итак, моя ветка main хранится в .git/refs/heads/main, и ее содержимое

$ cat .git/refs/heads/main
7f54a437d87cd1f241cfb893c4823bc7e60c19ec

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

  • история ветвей, как сообщается в сообщениях фиксации
  • кто внес изменения и когда они были внесены
  • отношения между различными ветвями и тегами

Когда я создаю простой тег:

В .git/refs/tags создается файл:

$ cat .git/refs/tags/v1
7f54a437d87cd1f241cfb893c4823bc7e60c19ec

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

  • текущая ветка перемещается в новый коммит
  • теги оставляем без изменений

Краткое содержание

Большой двоичный объект, дерево и коммиты — это то, как Git хранит полную историю вашего репозитория. Он делает все ссылки по хэшу объекта: невозможно манипулировать историей или файлами, отслеживаемыми в репозитории, не нарушая отношения.

Считаете ли вы эту статью полезной? Подпишитесь, чтобы получать уведомления, когда я публикую новые статьи по программированию и JavaScript.

Первоначально опубликовано на https://how-to.dev.