Объектная модель Git: blob, tree, commit

Открываем капот: как git на самом деле хранит ваши файлы, коммиты и историю.

Объектная модель git — это маленькая база данных типа «ключ-значение», где ключ — хеш содержимого, а значение — один из четырёх объектов: blob, tree, commit или tag.

Вы уже умеете коммитить, ветвиться и сливать ветки. Но что такое коммит физически? Где лежат файлы? Почему хеш коммита выглядит как a1b2c3d? Ответы на эти вопросы превращают git из набора заклинаний в понятный инструмент: вы перестаёте бояться reset, начинаете чинить «безнадёжно сломанную» историю и понимаете, почему git такой быстрый.

Зачем это знать на практике

Понимание объектной модели — не академическая роскошь. Оно напрямую экономит время в трёх ситуациях:

  • Восстановление данных. Зная, что коммиты — это объекты с хешами, вы понимаете, что «удалённый» коммит обычно физически на месте, и его можно вернуть.
  • Отладка странного поведения. Когда merge или rebase ведёт себя неожиданно, привычка думать «какой объект на что указывает» быстро выводит на причину.
  • Уверенность. Команды вроде reset --hard перестают пугать, когда вы знаете, что они двигают указатели, а не стирают историю.

Content-addressable storage

Git — это content-addressable хранилище: адрес объекта вычисляется из его содержимого. Берём содержимое, прогоняем через хеш-функцию, получаем 40-символьную строку в шестнадцатеричном виде (SHA-1) — это и есть имя объекта. Одинаковое содержимое всегда даёт один и тот же хеш, поэтому два одинаковых файла хранятся ровно один раз.

Проверим руками. Команда git hash-object считает хеш так же, как это делает git:

echo "hello" | git hash-object --stdin
# ce013625030ba8dba906f756967f9e9ca394464a

Этот хеш не случайный. Git берёт не голый текст, а добавляет заголовок: тип объекта, пробел, длину в байтах и нулевой байт. Для нашего примера склеивается строка blob 6\0hello\n, и уже от неё считается SHA-1. Поэтому пустой файл и файл со словом «hello» дают разные предсказуемые хеши на любой машине в мире.

Четыре типа объектов

В хранилище живут всего четыре вида объектов. Большую часть истории составляют первые три.

ОбъектЧто хранит
blobСодержимое одного файла — только байты, без имени и прав.
treeКаталог: список имён с правами и ссылками на blob'ы и вложенные tree.
commitСнимок: ссылка на корневой tree, родитель(и), автор, дата, сообщение.
tagАннотированный тег: ссылка на объект, имя тегировщика, дата, сообщение.

blob — содержимое файла

Blob хранит только байты файла. В нём нет имени файла, нет даты, нет прав доступа — всё это живёт уровнем выше, в tree. Поэтому если у вас в проекте два файла с одинаковым содержимым (скажем, две пустые __init__.py), git хранит один blob и ссылается на него дважды.

tree — каталог

Tree — это и есть «папка». Каждая его строка — это режим доступа, тип, хеш и имя. Заглянуть внутрь дерева последнего коммита можно так:

git cat-file -p HEAD^{tree}
# 100644 blob 1f7a7a4...    README.md
# 040000 tree 9d2e3b1...    src

Режим 100644 — обычный файл, 100755 — исполняемый, 040000 — вложенный каталог (ещё один tree). Так из плоских объектов собирается дерево всего проекта.

commit — снимок во времени

Коммит на удивление маленький. Он не содержит файлов — только ссылку на корневой tree и метаданные:

git cat-file -p HEAD
# tree 8e5a9c...
# parent 4b6f0d...
# author Anna <[email protected]> 1718000000 +0300
# committer Anna <[email protected]> 1718000000 +0300
#
# Добавить форму входа

Строка parent связывает коммиты в цепочку — так получается история. У обычного коммита один родитель, у merge-коммита их два и больше, у самого первого коммита родителя нет вовсе.

Снимки, а не дельты

Это ключевая идея, отличающая git от старых систем (Subversion, CVS). Они хранили историю как дельты — наборы изменений «что добавилось и удалилось» от версии к версии. Git хранит полные снимки: каждый коммит ссылается на дерево, описывающее состояние всех файлов целиком.

Звучит расточительно, но спасает дедупликация по содержимому. Если между коммитами файл не менялся, его blob имеет тот же хеш — и новый tree просто ссылается на старый blob. Не дублируется ни байта. Изменился один файл из тысячи — появится один новый blob и цепочка новых tree до корня, всё остальное переиспользуется. Git хранит снимки, но платит только за реальные изменения.

SHA-1 и переход на SHA-256

Исторически git использует SHA-1 — 160-битный хеш, те самые 40 hex-символов. В 2017 году исследователи показали практическую коллизию SHA-1 (атака SHAttered). Для git риск ограничен: репозиторий — не средство защиты от злоумышленника с доступом к истории, плюс начиная с версии 2.13 включена защита от известного класса коллизий. Тем не менее проект движется к SHA-256: его можно выбрать при создании репозитория.

git init --object-format=sha256 myrepo

В таком репозитории хеши становятся 64-символьными. Совместимость SHA-1 и SHA-256 репозиториев между собой пока ограничена, поэтому в реальных проектах по умолчанию всё ещё SHA-1 — но саму архитектуру это не меняет: модель «хеш содержимого → объект» одинакова.

Как это работает под капотом

Соберём цепочку целиком. Когда вы делаете git commit, git: (1) для каждого файла в индексе создаёт или переиспользует blob; (2) строит tree-объекты, описывающие каталоги; (3) создаёт commit-объект, ссылающийся на корневой tree и на предыдущий коммит. Все объекты записываются в каталог .git/objects/ под именем своего хеша. Указатель текущей ветки сдвигается на новый коммит.

Важно: объекты иммутабельны. Изменить существующий объект нельзя — любое изменение содержимого даёт другой хеш, то есть другой объект. Поэтому git commit --amend и rebase на самом деле не «правят» коммиты, а создают новые объекты и переставляют на них указатели. Старые объекты остаются в базе, пока их не уберёт сборка мусора.

Частые ошибки

  • «Git хранит дельты». Нет, на уровне модели это снимки; дельты появляются только при упаковке в packfile (об этом — в уроке про .git), и это деталь хранения, а не модели.
  • «Хеш коммита зависит только от файлов». Нет: в него входят автор, дата, сообщение и родитель. Поэтому два коммита с идентичными файлами, но разным временем — это разные объекты с разными хешами.
  • «hash-object что-то меняет в репозитории». Без флага -w команда только считает хеш и ничего не записывает; -w добавляет объект в базу.
  • Путать blob и файл. Blob не знает своего имени — имя хранится в tree. Один blob может быть «несколькими файлами» одновременно.

Итоги

  • Git — content-addressable хранилище: имя объекта = хеш его содержимого (SHA-1, опционально SHA-256).
  • Четыре объекта: blob (файл), tree (каталог), commit (снимок + метаданные), tag (аннотированный тег).
  • Коммиты хранят полные снимки, а не дельты; одинаковое содержимое дедуплицируется по хешу.
  • Объекты иммутабельны: amend и rebase создают новые объекты, а не правят старые.
  • Инструменты для исследования: git hash-object и git cat-file -p <hash>.
Проверьте себя
1. Что физически хранит объект типа blob в git?
AСодержимое одного файла (только байты), без имени и прав
BФайл вместе с его именем и правами доступа
CСписок файлов каталога со ссылками на содержимое
DРазницу (дельту) между двумя версиями файла
2. Почему git называют content-addressable хранилищем?
AПотому что объекты адресуются по дате создания
BПотому что имя (адрес) объекта вычисляется как хеш его содержимого
CПотому что адрес объекта задаёт пользователь вручную
DПотому что файлы адресуются по их пути в проекте
3. Чем подход git к хранению истории отличается от старых систем вроде Subversion?
AGit хранит дельты, а Subversion — полные снимки
BGit хранит полные снимки состояния, переиспользуя неизменившиеся blob'ы по хешу
CGit вообще не хранит историю файлов, только последнюю версию
DGit хранит только текстовые файлы, а бинарные игнорирует