Git - это content-addressable файловая система
Запомни главную мысль, всё остальное вырастает из неё: в основе Git лежит не "система контроля версий", а простое key-value хранилище. Ты даёшь ему кусок данных - он возвращает ключ. Даёшь тот же ключ - он возвращает те же данные. Вот и всё хранилище.
Хитрость в том, что ключ - это не случайный номер и не автоинкремент. Ключ вычисляется ИЗ самого содержимого: берётся SHA-хеш данных. Такой подход называется content-addressable storage, адресация по содержимому. У него есть мощное следствие: если содержимое изменилось хоть на один байт - изменился и адрес. А если два файла побайтово одинаковы - у них один и тот же адрес, и физически они хранятся ОДИН раз. Это и есть дедупликация, бесплатная и автоматическая.
Все эти кусочки данных Git называет объектами и складывает в каталог .git/objects. Когда говорят "git internals" или "как работает git внутри", на 90 процентов имеют в виду именно объектную базу. Объектов всего четыре типа, и их стоит знать наизусть:
- blob - содержимое одного файла. Голые байты, без имени, без прав, без даты. Просто "вот эти данные".
- tree - каталог. Список записей вида "режим + имя + ссылка на blob или на вложенный tree".
- commit - снимок: ссылка на один корневой tree, ссылки на родителей, автор, коммиттер, сообщение.
- tag - аннотированный тег: ссылка на объект (обычно commit), имя тега, автор тега, сообщение, опционально подпись.

SHA как адрес: как он вычисляется на самом деле
Тут многие спотыкаются, поэтому медленно. Хеш объекта - это НЕ хеш просто содержимого файла. Git сначала добавляет к содержимому заголовок, а потом хеширует всё вместе. Формула такая:
Код: Выделить всё
sha( "<тип> <длина-в-байтах>\0<содержимое>" )
Код: Выделить всё
$ printf 'hello\n' > a.txt
$ git hash-object a.txt
ce013625030ba8dba906f756967f9e9ca394464a
Код: Выделить всё
$ printf 'blob 6\0hello\n' | shasum
ce013625030ba8dba906f756967f9e9ca394464a -
Код: Выделить всё
$ git hash-object -w a.txt
ce013625030ba8dba906f756967f9e9ca394464a
git cat-file: смотрим внутрь объектов
Раз есть адреса, нужна команда, чтобы по адресу доставать содержимое и метаданные. Это git cat-file, твой основной инструмент для прогулок по базе. Три ключевых флага:
- -t - тип объекта (blob / tree / commit / tag)
- -s - размер содержимого в байтах
- -p - "pretty print", человекочитаемый вывод с учётом типа
Код: Выделить всё
$ git cat-file -t ce01362
blob
$ git cat-file -s ce01362
6
$ git cat-file -p ce01362
hello
Код: Выделить всё
$ git add a.txt
$ git write-tree
2e81171448eb9f2ee3821e3d447aa6b2fe3ddba1
$ git cat-file -p 2e81171
100644 blob ce013625030ba8dba906f756967f9e9ca394464a a.txt
Потоковый обход базы: --batch и --batch-check
Когда объектов десятки тысяч, дёргать cat-file по одному медленно - каждый запуск это новый процесс. Для массовой работы есть пакетные режимы, которые читают список объектов со stdin и отвечают потоком, не перезапускаясь. Это то, чем под капотом пользуются GitHub, GitLab и любой серьёзный инструментарий над Git.
Код: Выделить всё
$ echo ce01362 | git cat-file --batch-check
ce013625030ba8dba906f756967f9e9ca394464a blob 6
Код: Выделить всё
$ git cat-file --batch-check --batch-all-objects
2e81171448eb9f2ee3821e3d447aa6b2fe3ddba1 tree 33
ce013625030ba8dba906f756967f9e9ca394464a blob 6
Собираем commit руками: магия становится механикой
Теперь главное упражнение урока. У нас есть tree - снимок состояния. Превратим его в коммит низкоуровневой командой git commit-tree: она берёт адрес дерева, сообщение со stdin (или через -m) и при желании родителей через -p, а возвращает адрес нового commit-объекта.
Код: Выделить всё
$ git commit-tree 2e81171 -m "first commit by hand"
2c6121f56cc9b8bab3dbbcff9b52b8c845a735d7
$ git cat-file -p 2c6121f
tree 2e81171448eb9f2ee3821e3d447aa6b2fe3ddba1
author tester <a@b.c> 1781546774 +0300
committer tester <a@b.c> 1781546774 +0300
first commit by hand
Но git log его пока не покажет. Почему? Потому что мы создали объект, но ни одна ветка на него не смотрит. Ветка в Git - это просто файл-указатель с адресом коммита. Направим main на наш рукотворный коммит и заглянем в лог:
Код: Выделить всё
$ git update-ref refs/heads/main 2c6121f
$ git log --oneline
2c6121f first commit by hand
Для полноты картины ещё два плоумбинг-инструмента. git mktree собирает tree напрямую из текстового описания записей (минуя индекс) - полезно, когда лепишь деревья скриптом. А git mktag создаёт аннотированный tag-объект из описания на stdin:
Код: Выделить всё
$ printf 'object %s\ntype commit\ntag handmade\ntagger tester <a@b.c> 1700000000 +0000\n\nmade by mktag\n' 2c6121f | git mktag
30e3ffa078d06d8388c676a99c189dc9de4735e2
$ git cat-file -t 30e3ffa
tag
Как HEAD -> commit -> tree -> blob образуют снимок
Сложим всё в единую цепочку, ради которой урок и затевался. Когда ты делаешь checkout, Git идёт по ссылкам сверху вниз:
- HEAD указывает на ветку (refs/heads/main) или прямо на коммит.
- commit указывает на ровно один корневой tree - это и есть снимок ВСЕГО проекта на момент коммита.
- tree указывает на blob-ы (файлы) и на вложенные tree (подкаталоги), те - дальше вглубь.
- blob - это конечные листья, содержимое файлов.
Эту структуру можно обойти целиком командой git rev-list --objects - она перечисляет все объекты, достижимые от веток, вместе с их путями:
Код: Выделить всё
$ git rev-list --objects --all
2c6121f56cc9b8bab3dbbcff9b52b8c845a735d7
2e81171448eb9f2ee3821e3d447aa6b2fe3ddba1
ce013625030ba8dba906f756967f9e9ca394464a a.txt
Loose objects, zlib и физика на диске
Где всё это физически лежит? Каждый свежий объект пишется отдельным файлом - это loose object, "рыхлый объект". Адрес из 40 символов разбивается: первые 2 символа становятся именем подкаталога, остальные 38 - именем файла. Так Git избегает каталога с миллионом файлов в одной папке.
Код: Выделить всё
$ ls .git/objects/2e/
81171448eb9f2ee3821e3d447aa6b2fe3ddba1
Когда loose-объектов накапливается много, они неэффективны, и Git упаковывает их в packfile-ы при обслуживании. На 2026 году дефолтная стратегия ручного обслуживания (git maintenance run, начиная с Git 2.54) - геометрическая упаковка, а старый прямолинейный git gc стал легаси-путём. Объекты, ставшие недостижимыми, не стираются мгновенно, а складываются в cruft-пакеты с отметками времени .mtimes, чтобы их можно было подержать перед удалением. Но это уже тема следующего урока про упаковку - сейчас держим в голове только loose-уровень.
Типичные грабли
- Путают хеш файла и хеш blob. git hash-object файла НЕ равен sha256sum/md5sum того же файла - Git добавляет заголовок "blob <длина>\0". Всегда помни про заголовок.
- Думают, что blob хранит имя файла. Нет. Имя и режим живут в tree. Один и тот же blob может фигурировать под разными именами в разных деревьях.
- Ждут, что commit-tree сразу появится в git log. Не появится, пока на него не смотрит ни одна ветка/HEAD. Объект есть, но он недостижим. Нужен update-ref.
- cat на файл в .git/objects показывает мусор и человек решает, что репо побит. Это просто zlib-сжатие. Читай через git cat-file -p.
- Считают, что одинаковая правка в двух ветках = два хранения. Нет: одинаковое содержимое = один blob по одному адресу, дедупликация работает поверх всего репозитория.
- Боятся, что снимок на каждый коммит = копия всего проекта на диск. Нет: неизменённые объекты переиспользуются по адресу, дублируется только реально новое.
Повтори руками, не подсматривая в текст выше. Засекай - на это уходит минут пять, а понимания прибавляет на годы.
Код: Выделить всё
$ mkdir lab && cd lab && git init
$ printf 'hello\n' > a.txt
# 1. посчитай адрес сам и сверь с Git
$ printf 'blob 6\0hello\n' | shasum
$ git hash-object a.txt # должно совпасть
# 2. запиши blob и tree в базу
$ git add a.txt
$ git write-tree # запомни адрес дерева T
# 3. собери коммит из дерева
$ git commit-tree <T> -m "by hand" # запомни адрес коммита C
# 4. посмотри коммит изнутри
$ git cat-file -p <C>
# 5. направь ветку и проверь лог
$ git update-ref refs/heads/main <C>
$ git log --oneline
# 6. обойди всю базу двумя способами
$ git cat-file --batch-check --batch-all-objects
$ git rev-list --objects --all
Контрольные вопросы
- Почему git hash-object файла не совпадает с обычным sha256sum того же файла? Что именно Git добавляет перед хешированием?
- Ты собрал объект через git commit-tree, но git log его не видит. Что произошло и какой одной командой это чинится?
- В двух разных каталогах лежат побайтово одинаковые файлы. Сколько blob-объектов создаст Git и почему?
- Чем отличается то, на что ссылается commit, от того, что ссылается tree? Что именно хранит каждый из них?
Git внутри - это content-addressable хранилище с четырьмя типами объектов. blob держит содержимое файла, tree - структуру каталога с именами и режимами, commit - снимок одного корневого дерева плюс родители и метаданные, tag - аннотированную метку. Адрес каждого объекта - это SHA от строки "<тип> <длина>\0<содержимое>", поэтому одинаковые данные хранятся один раз, а любое изменение даёт новый адрес. Плоумбинг (hash-object, cat-file, write-tree, commit-tree, mktree, mktag, rev-list --objects) даёт прямой доступ к этим кирпичам, и ты лично собрал из них живой коммит. Цепочка HEAD -> commit -> tree -> blob и есть полный снимок проекта. Дальше будет про то, как Git упаковывает всё это в packfile-ы и делает быстрым на больших репозиториях.