Хранилище объектов — blob, tree, commit, tag
Git хранит всё как четыре типа объектов с адресацией по содержимому: blob (байты файла), tree (каталог), commit (одно дерево плюс родители и метаданные) и tag. Имя объекта — это SHA его содержимого, поэтому одинаковое содержимое хранится один раз. Смотри через git cat-file.
Ты делал git commit тысячу раз. Но что такое коммит на диске? Не дифф, не строка в базе данных — это крошечный файл, чьё имя есть хеш его собственного содержимого, указывающий на другие файлы, чьи имена тоже хеши их содержимого. Как только ты это увидишь, ветки, слияния и reset перестанут быть магией и станут арифметикой указателей над хранилищем объектов, в которое только дописывают.
К концу этого урока ты сможешь назвать четыре типа объектов git, объяснить, почему их имена — это хеши содержимого, и спуститься от коммита к байтам файла с помощью git cat-file.
После этого урока ты сможешь описать четыре типа объектов git — blob, tree, commit, tag — объяснить адресацию по содержимому и дедупликацию, которую она даёт, и использовать git cat-file и git hash-object, чтобы рассмотреть реальные объекты за коммитом.
Всё, что хранит git, — это один из четырёх типов объектов. Под .git/objects git держит key-value-хранилище неизменяемых объектов, каждый из ровно четырёх видов:
- blob — сырые байты содержимого одного файла. У blob нет имени файла и нет прав; это просто содержимое.
- tree — один каталог. Tree — это список записей, каждая
mode + type + hash + name, указывающих на blob (файлы) и другие tree (подкаталоги). Имена живут здесь, а не в blob. - commit — снимок. Указывает ровно на одно корневое дерево, перечисляет ноль или больше родительских коммитов и несёт автора, коммиттера, метки времени и твоё сообщение.
- tag — объект аннотированного тега: имя, указывающее на другой объект (обычно коммит), плюс тэггер и сообщение. (Легковесные теги — это просто ссылки, объект они не создают.)
Имя объекта — это хеш его содержимого; это и есть адресация по содержимому. Git берёт тип объекта, длину и байты, хеширует их и использует этот хеш как адрес объекта. Исторически хеш — это SHA-1 (40 hex-символов); git мигрирует на SHA-256. Следствие огромно: имя выводится из содержимого, поэтому ты не можешь изменить содержимое, не изменив имя.
# Захешировать содержимое так, как это делает git, не сохраняя его
echo 'hello' | git hash-object --stdin
# -> ce013625030ba8dba906f756967f9e9ca394464aОдинаковое содержимое хранится ровно один раз. Поскольку адрес — это хеш содержимого, два файла с одинаковыми байтами — или один и тот же неизменный файл в сотне коммитов — дают одинаковый blob-хеш и занимают один объект. Коммит, который трогает один файл в дереве из 10 000 файлов, не копирует остальные 9 999 blob; его новые tree просто заново указывают на неизменные хеши. Снимки дёшевы именно потому, что общее содержимое разделяется по адресу.
git rev-parse HEAD # хеш объекта-коммита
git cat-file -t HEAD # -> commit (спросить тип объекта)
git cat-file -p HEAD # -> красиво вывести: tree, parent, author, message▸Почему это работает
Адресация по содержимому — это ещё и проверка целостности git. Если байт объекта повреждён на диске, его содержимое больше не совпадает с именем, и git fsck (или любое чтение, которое его перехеширует) обнаружит расхождение. Вся история — это Merkle DAG: хеш коммита фиксирует хеш своего дерева, который фиксирует каждый blob-хеш под ним, а это значит, что один SHA на вершине криптографически фиксирует всю достижимую историю. Измени что-нибудь глубоко в прошлом — и каждый хеш-потомок изменится.
Ты можешь спуститься от коммита к байтам файла вручную. Каждый cat-file -p снимает один слой:
git cat-file -p HEAD # commit -> показывает "tree <hash>"
git cat-file -p HEAD^{tree} # tree -> строки: mode type hash name
git cat-file -p <blob-hash> # blob -> реальное содержимое файлаСтрока дерева вроде 100644 blob a1b2c3… README.md говорит: обычный файл (100644), хранится как blob a1b2c3…, имя README.md. Исполняемые файлы используют режим 100755; подкаталоги выглядят как 040000 tree …. Это и есть вся модель файловой системы.
Loose-объекты упаковываются ради эффективности. Только что записанный объект — это loose-объект: один сжатый zlib файл по пути .git/objects/ab/cdef… (первые два hex-символа — каталог). Когда их накапливается много, git gc переписывает многие из них в packfile (.git/objects/pack/*.pack), который хранит объекты вместе и дельта-сжимает похожие друг против друга. Те же идентичности с адресацией по содержимому, но более плотное хранение — поэтому репозиторий с глубокой историей куда меньше суммы своих снимков.
Доказываем дедупликацию двумя коммитами.
Инициализируем репозиторий и коммитим файл:
git init demo && cd demo
printf 'hello\n' > a.txt
git add a.txt && git commit -m "add a"
git rev-parse HEAD:a.txt # -> ce013625… (blob-хеш для a.txt)Теперь копируем те же байты во второй файл и коммитим:
cp a.txt b.txt
git add b.txt && git commit -m "add b (те же байты)"
git rev-parse HEAD:b.txt # -> ce013625… (ИДЕНТИЧНЫЙ хеш)Оба файла называют один и тот же blob — git сохранил содержимое один раз и направил на него две записи дерева. Два коммита отличаются только своими деревьями (которые теперь перечисляют два имени) и ссылками на родителя. Измени один байт b.txt — и его blob-хеш полностью изменится; старый blob останется, по-прежнему достижимый из первого коммита. Ничего никогда не правится на месте — хранилище только растёт.
▸Частая ошибка
Частое заблуждение: «blob хранит имена файлов и права». Нет. Blob — это только содержимое; именно поэтому одинаковый файл под двумя именами дедуплицируется в один blob. Имена и режимы живут в записи tree, которая указывает на blob. Из-за этого же разделения переименование файла с неизменным содержимым вообще не добавляет нового blob; меняется только дерево.
Два разных файла в одном коммите содержат побайтово идентичное содержимое. Сколько объектов blob git сохранит для них?
Git — это хранилище объектов с адресацией по содержимому и четырьмя типами: blob держат байты файлов, tree — это каталоги, отображающие имена и режимы на хеши blob/tree, commit указывают на одно дерево плюс родительские коммиты и метаданные, а tag аннотируют другой объект. Имя каждого объекта — это SHA его содержимого, что даёт бесплатную дедупликацию (одинаковое содержимое хранится один раз) и целостность (повреждение меняет имя). Любой коммит можно спустить до байтов через git cat-file -p, а git gc упаковывает loose-объекты в дельта-сжатые packfile. Дальше ты увидишь, как удобные для человека ссылки — ветки, теги и HEAD — это просто имена, указывающие в это хранилище.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.